mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-29 21:57:14 +00:00
Merge pull request #2439 from langbot-app/fix/recent-issues-20260817
fix recent migration and plugin command regressions
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import typing
|
import typing
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
|
from ..api.http.context import ExecutionContext
|
||||||
from ..core import app
|
from ..core import app
|
||||||
from . import operator
|
from . import operator
|
||||||
from ..utils import importutil
|
from ..utils import importutil
|
||||||
@@ -66,7 +67,14 @@ class CommandManager:
|
|||||||
|
|
||||||
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
||||||
if require_context is not None:
|
if require_context is not None:
|
||||||
result = require_context(context)
|
result = require_context(
|
||||||
|
ExecutionContext(
|
||||||
|
instance_uuid=context.instance_uuid,
|
||||||
|
workspace_uuid=context.workspace_uuid,
|
||||||
|
placement_generation=context.placement_generation,
|
||||||
|
query_uuid=context.query_uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
@@ -117,8 +118,19 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
|
|||||||
temporary_path.unlink(missing_ok=True)
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _fsync_file(path: pathlib.Path) -> None:
|
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
||||||
descriptor = os.open(path, os.O_RDONLY)
|
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
|
||||||
|
|
||||||
|
descriptor: int | None = None
|
||||||
|
for attempt in range(reopen_attempts):
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY)
|
||||||
|
break
|
||||||
|
except FileNotFoundError:
|
||||||
|
if attempt + 1 >= reopen_attempts:
|
||||||
|
raise
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert descriptor is not None
|
||||||
try:
|
try:
|
||||||
os.fsync(descriptor)
|
os.fsync(descriptor)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import pytest
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from langbot.pkg.persistence import alembic_runner
|
from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
|
||||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||||
|
|
||||||
from .resource_migration_support import create_legacy_resource_schema
|
from .resource_migration_support import create_legacy_resource_schema
|
||||||
@@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
|||||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
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}')
|
||||||
|
real_open = os.open
|
||||||
|
transient_failures = 0
|
||||||
|
|
||||||
|
def transient_open(path, flags, *args, **kwargs):
|
||||||
|
nonlocal transient_failures
|
||||||
|
candidate = pathlib.Path(path)
|
||||||
|
if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0:
|
||||||
|
transient_failures += 1
|
||||||
|
raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate))
|
||||||
|
return real_open(path, flags, *args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount')
|
||||||
|
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||||
|
monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open)
|
||||||
|
|
||||||
|
await _manager(engine)._run_alembic_migrations()
|
||||||
|
|
||||||
|
assert transient_failures == 1
|
||||||
|
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||||
|
assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock
|
|||||||
|
|
||||||
from langbot.pkg.command import operator
|
from langbot.pkg.command import operator
|
||||||
from langbot.pkg.command.cmdmgr import CommandManager
|
from langbot.pkg.command.cmdmgr import CommandManager
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
from tests.factories import FakeApp, command_query
|
from tests.factories import FakeApp, command_query
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
@@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute:
|
|||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0].text == 'plugin response'
|
assert results[0].text == 'plugin response'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_selects_workspace_with_trusted_context(self):
|
||||||
|
"""Plugin command discovery receives the typed runtime scope."""
|
||||||
|
|
||||||
|
fake_app = FakeApp()
|
||||||
|
mgr = CommandManager(fake_app)
|
||||||
|
mgr.cmd_list = []
|
||||||
|
fake_app.plugin_connector.require_workspace_context = AsyncMock()
|
||||||
|
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
ctx = self._create_context(command='help')
|
||||||
|
ctx.instance_uuid = 'instance-a'
|
||||||
|
ctx.workspace_uuid = 'workspace-a'
|
||||||
|
ctx.placement_generation = 4
|
||||||
|
ctx.query_uuid = 'query-a'
|
||||||
|
|
||||||
|
async for _ in mgr._execute(ctx, mgr.cmd_list):
|
||||||
|
pass
|
||||||
|
|
||||||
|
selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0]
|
||||||
|
assert isinstance(selected, ExecutionContext)
|
||||||
|
assert selected.instance_uuid == 'instance-a'
|
||||||
|
assert selected.workspace_uuid == 'workspace-a'
|
||||||
|
assert selected.placement_generation == 4
|
||||||
|
assert selected.query_uuid == 'query-a'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_with_bound_plugins(self):
|
async def test_execute_with_bound_plugins(self):
|
||||||
"""_execute passes bound_plugins to plugin connector."""
|
"""_execute passes bound_plugins to plugin connector."""
|
||||||
|
|||||||
Reference in New Issue
Block a user