Merge remote-tracking branch 'origin/master' into dev/4.11.x

# Conflicts:
#	src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
#	src/langbot/pkg/api/http/service/bot.py
#	src/langbot/pkg/provider/runners/localagent.py
#	src/langbot/templates/metadata/pipeline/ai.yaml
#	tests/unit_tests/api/service/test_bot_service.py
#	tests/unit_tests/provider/runners/test_difysvapi_runner.py
#	tests/unit_tests/utils/test_safe_regex.py
#	web/src/app/infra/entities/adapter-categories.ts
#	web/src/app/wizard/page.tsx
#	web/src/i18n/locales/en-US.ts
#	web/src/i18n/locales/ja-JP.ts
#	web/src/i18n/locales/zh-Hans.ts
#	web/tests/e2e/plugin-page-auth.spec.ts
This commit is contained in:
Hyu
2026-08-31 17:17:47 +08:00
67 changed files with 3604 additions and 255 deletions
@@ -115,6 +115,7 @@ class _CapacityPluginRuntimeHandler:
def __init__(self) -> None:
self.bindings: dict[str, typing.Any] = {}
self.reconciled: tuple[typing.Any, ...] = ()
self.reconcile_timeout: float | None = None
def register_installation_binding(
self,
@@ -132,8 +133,14 @@ class _CapacityPluginRuntimeHandler:
def unregister_installation_binding(self, binding) -> None:
self.bindings.pop(binding.installation_uuid, None)
async def reconcile_plugin_installations(self, desired_states) -> dict:
async def reconcile_plugin_installations(
self,
desired_states,
*,
timeout: float | None = None,
) -> dict:
self.reconciled = tuple(desired_states)
self.reconcile_timeout = timeout
return {
'applied': [],
'removed': [],
@@ -1034,6 +1041,7 @@ class TestPostgreSQLTenantRuntime:
assert not mcp_loader._hosted_mcp_tasks
assert len(plugin_handler.reconciled) == workspace_count
assert len(plugin_handler.bindings) == workspace_count
assert plugin_handler.reconcile_timeout == 300.0
assert all(count == workspace_count for count in statement_counts.values()), statement_counts
if max_elapsed is not None:
assert elapsed <= max_elapsed
@@ -39,6 +39,35 @@ def _assert_verified_backup(payload: dict) -> None:
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
def _temporary_sqlite_files(root: pathlib.Path) -> list[pathlib.Path]:
return [*root.rglob('*.creating'), *root.rglob('*.restoring')]
async def test_backup_removes_stale_temporary_file_from_interrupted_run(tmp_path):
database_path = tmp_path / 'legacy-stale-backup.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
try:
await create_legacy_resource_schema(engine, instance_uuid='stale-backup')
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
backup_directory = tmp_path / 'migration-backups'
backup_directory.mkdir()
stale_path = backup_directory / '.legacy-stale-backup-pre-0009-old.creating'
unrelated_path = backup_directory / '.another-database-pre-0009-old.creating'
stale_path.write_bytes(b'interrupted backup')
unrelated_path.write_bytes(b'unrelated backup')
await sqlite_migration_backup.create_verified_backup(
engine,
source_revision='0008_mcp_resource_prefs',
target_revision='0009_workspace_tenancy',
)
assert not stale_path.exists()
assert unrelated_path.read_bytes() == b'unrelated backup'
finally:
await engine.dispose()
async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
database_path = tmp_path / 'legacy-with-backups.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
@@ -59,6 +88,7 @@ async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
}
for payload in payloads:
_assert_verified_backup(payload)
assert _temporary_sqlite_files(tmp_path) == []
finally:
await engine.dispose()
@@ -100,6 +130,7 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
assert restored[0]['status'] == 'restored_after_failure'
assert restored[0]['source_revision'] == '0009_workspace_tenancy'
_assert_verified_backup(restored[0])
assert _temporary_sqlite_files(tmp_path) == []
monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade)
await _manager(engine)._run_alembic_migrations()
@@ -108,6 +139,41 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
await engine.dispose()
async def test_restore_publish_failure_preserves_current_database(tmp_path, monkeypatch):
database_path = tmp_path / 'restore-publish-failure.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
try:
await create_legacy_resource_schema(engine, instance_uuid='restore-publish-failure')
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
backup = await sqlite_migration_backup.create_verified_backup(
engine,
source_revision='0008_mcp_resource_prefs',
target_revision='0009_workspace_tenancy',
)
stale_restore_path = tmp_path / f'.{database_path.name}.interrupted.restoring'
stale_restore_path.write_bytes(b'interrupted restore')
async with engine.begin() as connection:
await connection.execute(sa.text("UPDATE alembic_version SET version_num = 'failed-revision'"))
await engine.dispose()
database_before_restore = database_path.read_bytes()
real_replace = os.replace
def fail_restore_publish(source, destination):
if pathlib.Path(destination) == database_path:
raise OSError('simulated atomic publish failure')
return real_replace(source, destination)
monkeypatch.setattr(sqlite_migration_backup.os, 'replace', fail_restore_publish)
with pytest.raises(OSError, match='atomic publish failure'):
await sqlite_migration_backup.restore_verified_backup(engine, backup)
assert database_path.read_bytes() == database_before_restore
assert _temporary_sqlite_files(tmp_path) == []
finally:
await engine.dispose()
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
database_path = tmp_path / 'legacy-bind-mount.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')