feat(tenancy): implement workspace isolation

This commit is contained in:
Junyan Qin
2026-07-19 09:58:59 +08:00
parent 37099ddf7e
commit 8b7ce77cec
271 changed files with 31166 additions and 6513 deletions
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import enum
import types
import typing
from .context import RequestContext
class WorkspaceRole(enum.StrEnum):
OWNER = 'owner'
ADMIN = 'admin'
DEVELOPER = 'developer'
OPERATOR = 'operator'
VIEWER = 'viewer'
class Permission(enum.StrEnum):
WORKSPACE_VIEW = 'workspace.view'
WORKSPACE_UPDATE = 'workspace.update'
WORKSPACE_DELETE = 'workspace.delete'
OWNER_TRANSFER = 'owner.transfer'
MEMBER_VIEW = 'member.view'
MEMBER_INVITE = 'member.invite'
MEMBER_UPDATE_ROLE = 'member.update_role'
MEMBER_REMOVE = 'member.remove'
RESOURCE_VIEW = 'resource.view'
RESOURCE_MANAGE = 'resource.manage'
RUNTIME_OPERATE = 'runtime.operate'
PROVIDER_SECRET_MANAGE = 'provider_secret.manage'
API_KEY_MANAGE = 'api_key.manage'
AUDIT_VIEW = 'audit.view'
DATA_EXPORT = 'data.export'
BILLING_LINK_MANAGE = 'billing_link.manage'
_VIEW_PERMISSIONS = {
Permission.WORKSPACE_VIEW,
Permission.MEMBER_VIEW,
Permission.RESOURCE_VIEW,
}
_ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
{
WorkspaceRole.OWNER: frozenset(Permission),
WorkspaceRole.ADMIN: frozenset(
permission
for permission in Permission
if permission
not in {
Permission.WORKSPACE_DELETE,
Permission.OWNER_TRANSFER,
Permission.BILLING_LINK_MANAGE,
}
),
WorkspaceRole.DEVELOPER: frozenset(
_VIEW_PERMISSIONS
| {
Permission.RESOURCE_MANAGE,
Permission.RUNTIME_OPERATE,
Permission.PROVIDER_SECRET_MANAGE,
}
),
WorkspaceRole.OPERATOR: frozenset(_VIEW_PERMISSIONS | {Permission.RUNTIME_OPERATE}),
WorkspaceRole.VIEWER: frozenset(_VIEW_PERMISSIONS),
}
)
class AuthorizationError(Exception):
"""Base class for errors that map to an HTTP authorization response."""
status_code = 403
error_code = 'forbidden'
class WorkspaceRequiredError(AuthorizationError):
status_code = 400
error_code = 'workspace_required'
class PermissionDeniedError(AuthorizationError):
error_code = 'permission_denied'
def __init__(self, permission: str) -> None:
super().__init__(f'Missing Workspace permission: {permission}')
self.permission = permission
class EditionLimitError(AuthorizationError):
error_code = 'edition_limit'
def permissions_for_role(role: str | WorkspaceRole) -> frozenset[str]:
"""Return the canonical fixed permissions for a Workspace role."""
try:
parsed_role = WorkspaceRole(role)
except ValueError:
return frozenset()
return frozenset(permission.value for permission in _ROLE_PERMISSIONS[parsed_role])
def has_permission(ctx: RequestContext, permission: str | Permission) -> bool:
"""Return whether the context contains one effective permission."""
permission_value = permission.value if isinstance(permission, Permission) else permission
return permission_value in ctx.workspace.permissions
def require_permission(ctx: RequestContext, permission: str | Permission) -> None:
"""Raise a stable authorization error when a permission is missing."""
permission_value = permission.value if isinstance(permission, Permission) else permission
if not has_permission(ctx, permission_value):
raise PermissionDeniedError(permission_value)
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import dataclasses
import enum
class PrincipalType(enum.StrEnum):
"""Kinds of authenticated principals accepted by LangBot."""
ACCOUNT = 'account'
API_KEY = 'api_key'
SYSTEM = 'system'
PUBLIC_BOT = 'public_bot'
@dataclasses.dataclass(frozen=True, slots=True)
class PrincipalContext:
"""Authenticated identity before Workspace authorization is applied."""
principal_type: PrincipalType
account_uuid: str | None = None
api_key_uuid: str | None = None
@dataclasses.dataclass(frozen=True, slots=True)
class WorkspaceContext:
"""Workspace membership and effective permissions for one request."""
workspace_uuid: str
membership_uuid: str | None
role: str | None
permissions: frozenset[str]
membership_revision: int = 0
@dataclasses.dataclass(frozen=True, slots=True)
class RequestContext:
"""Trusted authorization context passed to HTTP services."""
instance_uuid: str
placement_generation: int
request_id: str
auth_type: str
principal: PrincipalContext
workspace: WorkspaceContext
entitlement_revision: int = 0
@property
def workspace_uuid(self) -> str:
"""Return the selected Workspace UUID."""
return self.workspace.workspace_uuid
@property
def account_uuid(self) -> str | None:
"""Return the Account UUID when the principal is an Account."""
return self.principal.account_uuid
@dataclasses.dataclass(frozen=True, slots=True)
class ExecutionContext:
"""Workspace context propagated to asynchronous and runtime work."""
instance_uuid: str
workspace_uuid: str
placement_generation: int
bot_uuid: str | None = None
pipeline_uuid: str | None = None
query_uuid: str | None = None
trigger_principal: PrincipalContext | None = None
@classmethod
def from_request(
cls,
ctx: RequestContext,
*,
bot_uuid: str | None = None,
pipeline_uuid: str | None = None,
query_uuid: str | None = None,
) -> ExecutionContext:
"""Create a runtime context without losing the tenant generation."""
return cls(
instance_uuid=ctx.instance_uuid,
workspace_uuid=ctx.workspace_uuid,
placement_generation=ctx.placement_generation,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
trigger_principal=ctx.principal,
)
+253 -43
View File
@@ -5,9 +5,18 @@ import typing
import enum
import quart
import traceback
import inspect
import uuid
from quart.typing import RouteCallable
from ....core import app
from ....utils import constants
from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
from ....workspace.errors import WorkspaceNotFoundError
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
if typing.TYPE_CHECKING:
from ....core.app import Application
# Maximum file upload size limit (10MB)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
@@ -33,6 +42,7 @@ class AuthType(enum.Enum):
"""Authentication type"""
NONE = 'none'
ACCOUNT_TOKEN = 'account-token'
USER_TOKEN = 'user-token'
API_KEY = 'api-key'
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
@@ -43,11 +53,11 @@ class RouterGroup(abc.ABC):
path: str
ap: app.Application
ap: Application
quart_app: quart.Quart
def __init__(self, ap: app.Application, quart_app: quart.Quart) -> None:
def __init__(self, ap: Application, quart_app: quart.Quart) -> None:
self.ap = ap
self.quart_app = quart_app
@@ -59,16 +69,37 @@ class RouterGroup(abc.ABC):
self,
rule: str,
auth_type: AuthType = AuthType.USER_TOKEN,
permission: Permission | str | None = None,
**options: typing.Any,
) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
"""Register a route"""
if auth_type == AuthType.ACCOUNT_TOKEN and permission is not None:
raise ValueError('Account-token routes cannot declare Workspace permissions')
def decorator(f: RouteCallable) -> RouteCallable:
nonlocal rule
rule = self.path + rule
async def handler_error(*args, **kwargs):
if auth_type == AuthType.USER_TOKEN:
if auth_type == AuthType.ACCOUNT_TOKEN:
authorization = quart.request.headers.get('Authorization', '')
if not authorization.startswith('Bearer '):
return self.http_status(401, -1, 'No valid user token provided')
token = authorization.removeprefix('Bearer ')
if not token:
return self.http_status(401, -1, 'No valid user token provided')
try:
_, user_email = await self._authenticate_account(token)
# Account-token routes deliberately stop before Workspace
# selection. They may bootstrap a selector, but cannot
# receive RequestContext or enforce Workspace permissions.
self._inject_handler_context(f, kwargs, user_email, None)
except Exception as e:
return self._auth_error_response(e)
elif auth_type == AuthType.USER_TOKEN:
# get token from Authorization header
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
@@ -76,18 +107,15 @@ class RouterGroup(abc.ABC):
return self.http_status(401, -1, 'No valid user token provided')
try:
user_email = await self.ap.user_service.verify_jwt_token(token)
# check if this account exists
user = await self.ap.user_service.get_user_by_email(user_email)
if not user:
return self.http_status(401, -1, 'User not found')
# check if f accepts user_email parameter
if 'user_email' in f.__code__.co_varnames:
kwargs['user_email'] = user_email
account, user_email = await self._authenticate_account(token)
request_context = await self._resolve_account_context(account, auth_type)
if permission is not None:
if request_context is None:
raise AuthorizationError('Workspace authorization is unavailable')
require_permission(request_context, permission)
self._inject_handler_context(f, kwargs, user_email, request_context)
except Exception as e:
return self.http_status(401, -1, str(e))
return self._auth_error_response(e)
elif auth_type == AuthType.API_KEY:
# get API key from Authorization header or X-API-Key header
@@ -101,11 +129,12 @@ class RouterGroup(abc.ABC):
return self.http_status(401, -1, 'No valid API key provided')
try:
is_valid = await self.ap.apikey_service.verify_api_key(api_key)
if not is_valid:
return self.http_status(401, -1, 'Invalid API key')
request_context = await self._authenticate_api_key(api_key, auth_type)
if permission is not None:
require_permission(request_context, permission)
self._inject_handler_context(f, kwargs, None, request_context)
except Exception as e:
return self.http_status(401, -1, str(e))
return self._auth_error_response(e)
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
# Try API key first (check X-API-Key header)
@@ -114,11 +143,12 @@ class RouterGroup(abc.ABC):
if api_key:
# API key authentication
try:
is_valid = await self.ap.apikey_service.verify_api_key(api_key)
if not is_valid:
return self.http_status(401, -1, 'Invalid API key')
request_context = await self._authenticate_api_key(api_key, auth_type)
if permission is not None:
require_permission(request_context, permission)
self._inject_handler_context(f, kwargs, None, request_context)
except Exception as e:
return self.http_status(401, -1, str(e))
return self._auth_error_response(e)
else:
# Try user token authentication (Authorization header)
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
@@ -129,35 +159,56 @@ class RouterGroup(abc.ABC):
)
try:
user_email = await self.ap.user_service.verify_jwt_token(token)
# check if this account exists
user = await self.ap.user_service.get_user_by_email(user_email)
if not user:
return self.http_status(401, -1, 'User not found')
# check if f accepts user_email parameter
if 'user_email' in f.__code__.co_varnames:
kwargs['user_email'] = user_email
account, user_email = await self._authenticate_account(token)
request_context = await self._resolve_account_context(account, auth_type)
if permission is not None:
if request_context is None:
raise AuthorizationError('Workspace authorization is unavailable')
require_permission(request_context, permission)
self._inject_handler_context(f, kwargs, user_email, request_context)
except (AuthorizationError, WorkspaceNotFoundError, MembershipPermissionError) as e:
# Authentication succeeded and authorization was
# evaluated. Do not reinterpret a denied user token
# as an API key, which would mask the stable 403/404.
return self._auth_error_response(e)
except Exception:
# If user token fails, maybe it's an API key in Authorization header
try:
is_valid = await self.ap.apikey_service.verify_api_key(token)
if not is_valid:
return self.http_status(401, -1, 'Invalid authentication credentials')
request_context = await self._authenticate_api_key(token, auth_type)
if permission is not None:
require_permission(request_context, permission)
self._inject_handler_context(f, kwargs, None, request_context)
except Exception as e:
return self.http_status(401, -1, str(e))
return self._auth_error_response(e)
try:
return await f(*args, **kwargs)
except Exception as e: # 自动 500
traceback.print_exc()
# return self.http_status(500, -2, str(e))
return self.http_status(500, -2, str(e))
if isinstance(e, AuthorizationError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, WorkspaceNotFoundError):
return self.http_status(404, 'resource_not_found', 'Resource not found')
if isinstance(e, MembershipPermissionError):
return self.http_status(403, e.code, str(e))
if isinstance(e, WorkspaceCollaborationError):
return self.http_status(400, e.code, str(e))
request_id = self.request_id()
logger = getattr(self.ap, 'logger', self.quart_app.logger)
logger.error(
f'Unhandled HTTP error request_id={request_id} '
f'method={quart.request.method} path={quart.request.path}\n{traceback.format_exc()}'
)
return self.internal_error_response(request_id)
new_f = handler_error
new_f.__name__ = (self.name + rule).replace('/', '__')
# Quart/Flask requires a unique endpoint name even when the same URL
# intentionally has separate handlers for different HTTP methods.
# Include the method set so CRUD routes can declare distinct
# permissions without colliding during application startup.
methods = options.get('methods') or ['GET']
method_suffix = '__'.join(sorted(str(method).upper() for method in methods))
new_f.__name__ = (self.name + rule + '__' + method_suffix).replace('/', '__')
new_f.__doc__ = f.__doc__
self.quart_app.route(rule, **options)(new_f)
@@ -165,6 +216,165 @@ class RouterGroup(abc.ABC):
return decorator
async def _authenticate_account(self, token: str) -> tuple[typing.Any, str]:
account: typing.Any = None
resolver = getattr(self.ap.user_service, 'get_authenticated_account', None)
if callable(resolver):
resolved = resolver(token)
if inspect.isawaitable(resolved):
account = await resolved
if isinstance(account, str) or account is None:
user_email = account or await self.ap.user_service.verify_jwt_token(token)
account = await self.ap.user_service.get_user_by_email(user_email)
if account is None:
raise ValueError('User not found')
return account, account.user
async def _resolve_account_context(
self,
account: typing.Any,
auth_type: AuthType,
) -> RequestContext | None:
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
account_uuid = getattr(account, 'uuid', None)
# Compatibility for isolated controller tests that do not wire the tenancy kernel.
if collaboration_service is None or not isinstance(account_uuid, str):
return None
requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id')
access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid)
request_context = RequestContext(
instance_uuid=access.execution.instance_uuid,
placement_generation=access.execution.placement_generation,
request_id=self.request_id(),
auth_type=auth_type.value,
principal=PrincipalContext(
principal_type=PrincipalType.ACCOUNT,
account_uuid=account_uuid,
),
workspace=WorkspaceContext(
workspace_uuid=access.workspace.uuid,
membership_uuid=access.membership.uuid,
role=access.membership.role,
permissions=permissions_for_role(access.membership.role),
membership_revision=access.membership.projection_revision,
),
)
quart.g.request_context = request_context
quart.g.workspace_membership = access.membership
return request_context
async def _authenticate_api_key(self, api_key: str, auth_type: AuthType) -> RequestContext:
authenticator = getattr(self.ap.apikey_service, 'authenticate_api_key', None)
if callable(authenticator):
authenticated = authenticator(api_key)
if inspect.isawaitable(authenticated):
identity = await authenticated
if identity is not None:
request_context = RequestContext(
instance_uuid=identity.instance_uuid,
placement_generation=identity.placement_generation,
request_id=self.request_id(),
auth_type=auth_type.value,
principal=PrincipalContext(
principal_type=PrincipalType.API_KEY,
api_key_uuid=identity.api_key_uuid,
),
workspace=WorkspaceContext(
workspace_uuid=identity.workspace_uuid,
membership_uuid=None,
role=None,
permissions=identity.permissions,
),
)
quart.g.request_context = request_context
return request_context
if not await self.ap.apikey_service.verify_api_key(api_key):
raise ValueError('Invalid API key')
workspace_service = getattr(self.ap, 'workspace_service', None)
if workspace_service is None:
raise ValueError('API key Workspace binding is unavailable')
binding = await workspace_service.get_local_execution_binding()
request_context = RequestContext(
instance_uuid=binding.instance_uuid or constants.instance_id,
placement_generation=binding.placement_generation,
request_id=self.request_id(),
auth_type=auth_type.value,
principal=PrincipalContext(
principal_type=PrincipalType.API_KEY,
api_key_uuid='legacy-oss-api-key',
),
workspace=WorkspaceContext(
workspace_uuid=binding.workspace_uuid,
membership_uuid=None,
role=None,
permissions=frozenset(item.value for item in Permission),
),
)
quart.g.request_context = request_context
return request_context
@staticmethod
def _inject_handler_context(
handler: RouteCallable,
kwargs: dict[str, typing.Any],
user_email: str | None,
request_context: RequestContext | None,
) -> None:
parameters = handler.__code__.co_varnames
if user_email is not None and 'user_email' in parameters:
kwargs['user_email'] = user_email
if request_context is not None:
if 'request_context' in parameters:
kwargs['request_context'] = request_context
elif 'ctx' in parameters:
kwargs['ctx'] = request_context
def _auth_error_response(self, error: Exception) -> typing.Any:
if isinstance(error, AuthorizationError):
return self.http_status(error.status_code, error.error_code, str(error))
if isinstance(error, WorkspaceNotFoundError):
return self.http_status(404, 'resource_not_found', 'Resource not found')
if isinstance(error, MembershipPermissionError):
return self.http_status(403, error.code, str(error))
request_id = self.request_id()
logger = getattr(self.ap, 'logger', self.quart_app.logger)
logger.warning(f'Authentication failed request_id={request_id} error_type={type(error).__name__}: {error}')
return self.http_status(
401,
'invalid_authentication',
'Invalid authentication credentials',
)
def request_id(self) -> str:
"""Return one stable request ID for authentication, logs, and errors."""
request_context = getattr(quart.g, 'request_context', None)
request_id = getattr(request_context, 'request_id', None) or getattr(quart.g, 'request_id', None)
if not request_id:
candidate = str(quart.request.headers.get('X-Request-Id') or '').strip()
if not candidate or len(candidate) > 128 or any(ord(char) < 32 for char in candidate):
candidate = str(uuid.uuid4())
request_id = candidate
quart.g.request_id = request_id
return str(request_id)
def internal_error_response(self, request_id: str | None = None) -> typing.Tuple[quart.Response, int]:
"""Return a stable 500 response without exposing the underlying exception."""
resolved_request_id = request_id or self.request_id()
response = quart.jsonify(
{
'code': 'internal_error',
'msg': 'Internal server error',
'request_id': resolved_request_id,
}
)
response.headers['X-Request-Id'] = resolved_request_id
return response, 500
def success(self, data: typing.Any = None) -> quart.Response:
"""Return a 200 response"""
return quart.jsonify(
@@ -175,7 +385,7 @@ class RouterGroup(abc.ABC):
}
)
def fail(self, code: int, msg: str) -> quart.Response:
def fail(self, code: int | str, msg: str) -> quart.Response:
"""Return an error response"""
return quart.jsonify(
@@ -185,6 +395,6 @@ class RouterGroup(abc.ABC):
}
)
def http_status(self, status: int, code: int, msg: str) -> typing.Tuple[quart.Response, int]:
def http_status(self, status: int, code: int | str, msg: str) -> typing.Tuple[quart.Response, int]:
"""返回一个指定状态码的响应"""
return (self.fail(code, msg), status)
@@ -1,43 +1,66 @@
from __future__ import annotations
import datetime
import quart
from ...authz import Permission
from ...context import RequestContext
from .. import group
@group.group_class('apikeys', '/api/v1/apikeys')
class ApiKeysRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'])
async def _() -> str:
if quart.request.method == 'GET':
keys = await self.ap.apikey_service.get_api_keys()
return self.success(data={'keys': keys})
elif quart.request.method == 'POST':
json_data = await quart.request.json
name = json_data.get('name', '')
description = json_data.get('description', '')
@self.route('', methods=['GET'], permission=Permission.API_KEY_MANAGE)
async def _(request_context: RequestContext) -> str:
keys = await self.ap.apikey_service.get_api_keys(request_context)
return self.success(data={'keys': keys})
if not name:
return self.http_status(400, -1, 'Name is required')
@self.route('', methods=['POST'], permission=Permission.API_KEY_MANAGE)
async def _(request_context: RequestContext) -> str:
json_data = await quart.request.json
expires_at = json_data.get('expires_at')
parsed_expiry = None
if expires_at:
try:
parsed_expiry = datetime.datetime.fromisoformat(str(expires_at).replace('Z', '+00:00'))
except ValueError:
return self.http_status(400, 'invalid_expiry', 'Invalid API key expiry')
try:
key = await self.ap.apikey_service.create_api_key(
request_context,
json_data.get('name', ''),
json_data.get('description', ''),
scopes=json_data.get('scopes'),
expires_at=parsed_expiry,
)
except ValueError as error:
return self.http_status(400, 'invalid_api_key', str(error))
return self.success(data={'key': key})
key = await self.ap.apikey_service.create_api_key(name, description)
return self.success(data={'key': key})
@self.route('/<int:key_id>', methods=['GET'], permission=Permission.API_KEY_MANAGE)
async def _(key_id: int, request_context: RequestContext) -> str:
key = await self.ap.apikey_service.get_api_key(request_context, key_id)
if key is None:
return self.http_status(404, 'resource_not_found', 'API key not found')
return self.success(data={'key': key})
@self.route('/<int:key_id>', methods=['GET', 'PUT', 'DELETE'])
async def _(key_id: int) -> str:
if quart.request.method == 'GET':
key = await self.ap.apikey_service.get_api_key(key_id)
if key is None:
return self.http_status(404, -1, 'API key not found')
return self.success(data={'key': key})
@self.route('/<int:key_id>', methods=['PUT'], permission=Permission.API_KEY_MANAGE)
async def _(key_id: int, request_context: RequestContext) -> str:
json_data = await quart.request.json
try:
await self.ap.apikey_service.update_api_key(
request_context,
key_id,
json_data.get('name'),
json_data.get('description'),
)
except ValueError as error:
return self.http_status(400, 'invalid_api_key', str(error))
return self.success()
elif quart.request.method == 'PUT':
json_data = await quart.request.json
name = json_data.get('name')
description = json_data.get('description')
await self.ap.apikey_service.update_api_key(key_id, name, description)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.apikey_service.delete_api_key(key_id)
return self.success()
@self.route('/<int:key_id>', methods=['DELETE'], permission=Permission.API_KEY_MANAGE)
async def _(key_id: int, request_context: RequestContext) -> str:
await self.ap.apikey_service.delete_api_key(request_context, key_id)
return self.success()
@@ -2,6 +2,8 @@ from __future__ import annotations
from langbot.pkg.utils import constants
from ...authz import Permission
from ...context import RequestContext
from .. import group
from .box_visibility import should_hide_box_runtime_status
@@ -9,18 +11,33 @@ from .box_visibility import should_hide_box_runtime_status
@group.group_class('box', '/api/v1/box')
class BoxRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
status = await self.ap.box_service.get_status()
@self.route(
'/status',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
status = await self.ap.box_service.get_status(request_context)
status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
return self.success(data=status)
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
sessions = await self.ap.box_service.get_sessions()
@self.route(
'/sessions',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> str:
sessions = await self.ap.box_service.get_sessions(request_context)
return self.success(data=sessions)
@self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
errors = self.ap.box_service.get_recent_errors()
@self.route(
'/errors',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> str:
errors = self.ap.box_service.get_recent_errors(request_context)
return self.success(data=errors)
@@ -3,6 +3,9 @@ from __future__ import annotations
import asyncio
import quart
from ...authz import Permission
from ...context import RequestContext
from ...service.secrets import redact_secrets
from .. import group
@@ -11,12 +14,19 @@ class ExtensionsRouterGroup(group.RouterGroup):
"""Unified API for installed extensions (plugins, MCP servers, skills)."""
async def initialize(self) -> None:
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> quart.Response:
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> quart.Response:
if self.ap.plugin_connector.is_enable_plugin:
await self.ap.plugin_connector.require_workspace_context(request_context)
plugins, mcp_servers, skills = await asyncio.gather(
self.ap.plugin_connector.list_plugins(),
self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True),
self.ap.skill_service.list_skills(),
self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True),
self.ap.skill_service.list_skills(request_context),
return_exceptions=True,
)
@@ -39,7 +49,7 @@ class ExtensionsRouterGroup(group.RouterGroup):
extensions: list[dict] = []
if isinstance(plugins, list):
for plugin in plugins:
extensions.append({'type': 'plugin', 'plugin': plugin})
extensions.append({'type': 'plugin', 'plugin': redact_secrets(plugin)})
if isinstance(mcp_servers, list):
for server in mcp_servers:
extensions.append({'type': 'mcp', 'server': server})
@@ -7,29 +7,48 @@ import asyncio
import quart.datastructures
from ...authz import Permission
from ...context import RequestContext
from .. import group
def _storage_owner(context: RequestContext) -> str:
if context.principal.account_uuid:
return f'account:{context.principal.account_uuid}'
if context.principal.api_key_uuid:
return f'api-key:{context.principal.api_key_uuid}'
return f'principal:{context.principal.principal_type.value}'
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
async def _(image_key: str) -> quart.Response:
if '..' in image_key or '\\' in image_key:
image_bytes = await self.ap.storage_mgr.resolve_public_object(
image_key,
expected_owner_type='upload_image',
)
if image_bytes is None:
image_bytes = await self.ap.storage_mgr.resolve_public_object(
image_key,
expected_owner_type='bot_log',
)
if image_bytes is None:
return quart.Response(status=404)
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
return quart.Response(status=404)
image_bytes = await self.ap.storage_mgr.storage_provider.load(image_key)
mime_type = mimetypes.guess_type(image_key)[0]
if mime_type is None:
mime_type = 'image/jpeg'
return quart.Response(image_bytes, mimetype=mime_type)
@self.route('/images', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def upload_image() -> quart.Response:
@self.route(
'/images',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def upload_image(request_context: RequestContext) -> quart.Response:
request = quart.request
# Check file size limit before reading the file
@@ -66,18 +85,29 @@ class FilesRouterGroup(group.RouterGroup):
if '/' in file_name or '\\' in file_name:
return self.fail(400, 'File name contains invalid characters')
file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension
logical_key = f'{uuid.uuid4()}.{extension}'
# save file to storage
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
file_key = await self.ap.storage_mgr.save_scoped(
request_context,
owner_type='upload_image',
owner=_storage_owner(request_context),
key=logical_key,
value=file_bytes,
)
return self.success(
data={
'file_key': file_key,
}
)
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def upload_document() -> quart.Response:
@self.route(
'/documents',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def upload_document(request_context: RequestContext) -> quart.Response:
request = quart.request
# Check file size limit before reading the file
@@ -110,12 +140,18 @@ class FilesRouterGroup(group.RouterGroup):
if '/' in file_name or '\\' in file_name:
return self.fail(400, 'File name contains invalid characters')
file_key = file_name + '_' + str(uuid.uuid4())[:8]
logical_key = str(uuid.uuid4())
if extension:
file_key += '.' + extension
logical_key += '.' + extension
# save file to storage
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
file_key = await self.ap.storage_mgr.save_scoped(
request_context,
owner_type='upload_document',
owner=_storage_owner(request_context),
key=logical_key,
value=file_bytes,
)
return self.success(
data={
'file_id': file_key,
@@ -1,100 +1,146 @@
import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ... import group
@group.group_class('knowledge_base', '/api/v1/knowledge/bases')
class KnowledgeBaseRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['POST', 'GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def handle_knowledge_bases() -> quart.Response:
if quart.request.method == 'GET':
knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases()
return self.success(data={'bases': knowledge_bases})
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def handle_knowledge_bases(request_context: RequestContext) -> quart.Response:
knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases(
request_context,
include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
)
return self.success(data={'bases': knowledge_bases})
elif quart.request.method == 'POST':
json_data = await quart.request.json
try:
knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(json_data)
except ValueError as e:
return self.http_status(400, -1, str(e))
return self.success(data={'uuid': knowledge_base_uuid})
return self.http_status(405, -1, 'Method not allowed')
@self.route(
'',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def create_knowledge_base(request_context: RequestContext) -> quart.Response:
json_data = await quart.request.json
try:
knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(
request_context,
json_data,
)
except ValueError as e:
return self.http_status(400, -1, str(e))
return self.success(data={'uuid': knowledge_base_uuid})
@self.route(
'/<knowledge_base_uuid>',
methods=['GET', 'DELETE', 'PUT'],
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def handle_specific_knowledge_base(knowledge_base_uuid: str) -> quart.Response:
if quart.request.method == 'GET':
knowledge_base = await self.ap.knowledge_service.get_knowledge_base(knowledge_base_uuid)
async def get_specific_knowledge_base(
knowledge_base_uuid: str,
request_context: RequestContext,
) -> quart.Response:
knowledge_base = await self.ap.knowledge_service.get_knowledge_base(
request_context,
knowledge_base_uuid,
include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
)
if knowledge_base is None:
return self.http_status(404, 'resource_not_found', 'knowledge base not found')
return self.success(data={'base': knowledge_base})
if knowledge_base is None:
return self.http_status(404, -1, 'knowledge base not found')
return self.success(
data={
'base': knowledge_base,
}
)
elif quart.request.method == 'PUT':
@self.route(
'/<knowledge_base_uuid>',
methods=['DELETE', 'PUT'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def mutate_specific_knowledge_base(
knowledge_base_uuid: str,
request_context: RequestContext,
) -> quart.Response:
if quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.knowledge_service.update_knowledge_base(knowledge_base_uuid, json_data)
await self.ap.knowledge_service.update_knowledge_base(
request_context,
knowledge_base_uuid,
json_data,
)
return self.success(data={'uuid': knowledge_base_uuid})
elif quart.request.method == 'DELETE':
await self.ap.knowledge_service.delete_knowledge_base(knowledge_base_uuid)
return self.success({})
await self.ap.knowledge_service.delete_knowledge_base(request_context, knowledge_base_uuid)
return self.success({})
@self.route(
'/<knowledge_base_uuid>/files',
methods=['GET', 'POST'],
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def get_knowledge_base_files(knowledge_base_uuid: str) -> str:
if quart.request.method == 'GET':
files = await self.ap.knowledge_service.get_files_by_knowledge_base(knowledge_base_uuid)
return self.success(
data={
'files': files,
}
)
async def get_knowledge_base_files(
knowledge_base_uuid: str,
request_context: RequestContext,
) -> str:
files = await self.ap.knowledge_service.get_files_by_knowledge_base(
request_context,
knowledge_base_uuid,
)
return self.success(data={'files': files})
elif quart.request.method == 'POST':
json_data = await quart.request.json
file_id = json_data.get('file_id')
if not file_id:
return self.http_status(400, -1, 'File ID is required')
parser_plugin_id = json_data.get('parser_plugin_id')
# 调用服务层方法将文件与知识库关联
task_id = await self.ap.knowledge_service.store_file(
knowledge_base_uuid, file_id, parser_plugin_id=parser_plugin_id
)
return self.success(
{
'task_id': task_id,
}
)
@self.route(
'/<knowledge_base_uuid>/files',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def add_knowledge_base_file(
knowledge_base_uuid: str,
request_context: RequestContext,
) -> str:
json_data = await quart.request.json
file_id = json_data.get('file_id')
if not file_id:
return self.http_status(400, -1, 'File ID is required')
parser_plugin_id = json_data.get('parser_plugin_id')
task_id = await self.ap.knowledge_service.store_file(
request_context,
knowledge_base_uuid,
file_id,
parser_plugin_id=parser_plugin_id,
)
return self.success({'task_id': task_id})
@self.route(
'/<knowledge_base_uuid>/files/<file_id>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def delete_specific_file_in_kb(file_id: str, knowledge_base_uuid: str) -> str:
await self.ap.knowledge_service.delete_file(knowledge_base_uuid, file_id)
async def delete_specific_file_in_kb(
file_id: str,
knowledge_base_uuid: str,
request_context: RequestContext,
) -> str:
await self.ap.knowledge_service.delete_file(request_context, knowledge_base_uuid, file_id)
return self.success({})
@self.route(
'/<knowledge_base_uuid>/retrieve',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def retrieve_knowledge_base(knowledge_base_uuid: str) -> str:
async def retrieve_knowledge_base(
knowledge_base_uuid: str,
request_context: RequestContext,
) -> str:
json_data = await quart.request.json
query = json_data.get('query')
@@ -104,6 +150,9 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
# Extract retrieval_settings to allow dynamic control over Knowledge Engine behavior (e.g. top_k, filters)
retrieval_settings = json_data.get('retrieval_settings', {})
results = await self.ap.knowledge_service.retrieve_knowledge_base(
knowledge_base_uuid, query, retrieval_settings
request_context,
knowledge_base_uuid,
query,
retrieval_settings,
)
return self.success(data={'results': results})
@@ -1,25 +1,39 @@
import quart
from urllib.parse import unquote
from ....authz import Permission
from ....context import RequestContext
from ... import group
@group.group_class('knowledge_engines', '/api/v1/knowledge/engines')
class KnowledgeEnginesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def list_knowledge_engines() -> quart.Response:
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def list_knowledge_engines(request_context: RequestContext) -> quart.Response:
"""List all available Knowledge Engines from plugins.
Returns a list of Knowledge Engines with their capabilities and configuration schemas.
This is used by the frontend to render the knowledge base creation wizard.
"""
engines = await self.ap.knowledge_service.list_knowledge_engines()
engines = await self.ap.knowledge_service.list_knowledge_engines(request_context)
return self.success(data={'engines': engines})
@self.route(
'/<path:plugin_id>/creation-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
'/<path:plugin_id>/creation-schema',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def get_engine_creation_schema(plugin_id: str) -> quart.Response:
async def get_engine_creation_schema(
plugin_id: str,
request_context: RequestContext,
) -> quart.Response:
"""Get creation settings schema for a specific Knowledge Engine.
plugin_id is in 'author/name' format, captured via <path:> converter.
@@ -27,13 +41,19 @@ class KnowledgeEnginesRouterGroup(group.RouterGroup):
plugin_id = unquote(plugin_id)
if '/' not in plugin_id:
return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.')
schema = await self.ap.knowledge_service.get_engine_creation_schema(plugin_id)
schema = await self.ap.knowledge_service.get_engine_creation_schema(request_context, plugin_id)
return self.success(data={'schema': schema})
@self.route(
'/<path:plugin_id>/retrieval-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
'/<path:plugin_id>/retrieval-schema',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def get_engine_retrieval_schema(plugin_id: str) -> quart.Response:
async def get_engine_retrieval_schema(
plugin_id: str,
request_context: RequestContext,
) -> quart.Response:
"""Get retrieval settings schema for a specific Knowledge Engine.
plugin_id is in 'author/name' format, captured via <path:> converter.
@@ -41,5 +61,5 @@ class KnowledgeEnginesRouterGroup(group.RouterGroup):
plugin_id = unquote(plugin_id)
if '/' not in plugin_id:
return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.')
schema = await self.ap.knowledge_service.get_engine_retrieval_schema(plugin_id)
schema = await self.ap.knowledge_service.get_engine_retrieval_schema(request_context, plugin_id)
return self.success(data={'schema': schema})
@@ -6,8 +6,11 @@ import quart
import sqlalchemy
from ... import group
from ....authz import Permission
from ....context import ExecutionContext, RequestContext
from ......core import taskmgr
from ......entity.persistence import metadata as persistence_metadata
from ......workspace.errors import WorkspaceError, WorkspaceNotFoundError
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
LANGRAG_PLUGIN_AUTHOR = 'langbot-team'
@@ -34,21 +37,49 @@ EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = {
@group.group_class('knowledge/migration', '/api/v1/knowledge/migration')
class KnowledgeMigrationRouterGroup(group.RouterGroup):
async def _get_migration_flag(self) -> bool:
async def _require_local_migration_context(
self,
execution_context: ExecutionContext,
) -> ExecutionContext:
"""Fence legacy-table migration to the OSS singleton Workspace.
The backup tables predate Workspace scoping and are deliberately
instance-global. A cloud projection must therefore never be allowed
to inspect or restore them, even when it has a valid execution lease.
"""
try:
binding = await self.ap.workspace_service.get_local_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
except WorkspaceNotFoundError:
raise
except WorkspaceError as exc:
raise WorkspaceNotFoundError('RAG migration is unavailable') from exc
if binding.instance_uuid != execution_context.instance_uuid:
raise WorkspaceNotFoundError('RAG migration is unavailable')
return ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
)
async def _get_migration_flag(self, execution_context: ExecutionContext) -> bool:
"""Check if rag_plugin_migration_needed flag is set."""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_metadata.Metadata).where(
persistence_metadata.Metadata.key == 'rag_plugin_migration_needed'
)
sqlalchemy.select(persistence_metadata.WorkspaceMetadata.value)
.where(persistence_metadata.WorkspaceMetadata.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_metadata.WorkspaceMetadata.key == 'rag_plugin_migration_needed')
)
row = result.first()
return row is not None and row.value == 'true'
return result.scalar_one_or_none() == 'true'
async def _set_migration_flag(self, value: str):
async def _set_migration_flag(self, execution_context: ExecutionContext, value: str):
"""Set rag_plugin_migration_needed flag."""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_metadata.Metadata)
.where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed')
sqlalchemy.update(persistence_metadata.WorkspaceMetadata)
.where(persistence_metadata.WorkspaceMetadata.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_metadata.WorkspaceMetadata.key == 'rag_plugin_migration_needed')
.values(value=value)
)
@@ -70,7 +101,11 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
return result.first() is not None
async def _install_plugin_from_marketplace(
self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str
self,
execution_context: ExecutionContext,
plugin_id: str,
task_context: taskmgr.TaskContext,
space_url: str,
) -> None:
"""Install a single plugin from the marketplace."""
p_author, p_name = plugin_id.split('/', 1)
@@ -85,6 +120,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
if not p_version:
raise Exception(f'Could not determine latest version for {plugin_id}')
await self.ap.plugin_connector.require_workspace_context(execution_context)
await self.ap.plugin_connector.install_plugin(
PluginInstallSource.MARKETPLACE,
{
@@ -96,8 +132,15 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
)
self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.')
async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True):
async def _execute_rag_migration(
self,
execution_context: ExecutionContext,
task_context: taskmgr.TaskContext,
install_plugin: bool = True,
):
"""Execute RAG migration: install required plugins and restore backup data."""
execution_context = await self._require_local_migration_context(execution_context)
execution_context = await self.ap.plugin_connector.require_workspace_context(execution_context)
warnings = []
# Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs)
@@ -127,7 +170,14 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
for plugin_id in needed_plugins:
try:
await self._install_plugin_from_marketplace(plugin_id, task_context, space_url)
await self._install_plugin_from_marketplace(
execution_context,
plugin_id,
task_context,
space_url,
)
except WorkspaceNotFoundError:
raise
except Exception as e:
self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}')
task_context.trace(f'Plugin install note ({plugin_id}): {e}')
@@ -141,8 +191,11 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
engine_id_set: set[str] = set()
for i in range(max_retries):
try:
await self.ap.plugin_connector.require_workspace_context(execution_context)
engines = await self.ap.plugin_connector.list_knowledge_engines()
engine_id_set = {e.get('plugin_id') for e in engines}
except WorkspaceNotFoundError:
raise
except Exception:
pass
if all(pid in engine_id_set for pid in needed_plugins):
@@ -158,8 +211,11 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
await asyncio.sleep(2)
else:
try:
await self.ap.plugin_connector.require_workspace_context(execution_context)
engines = await self.ap.plugin_connector.list_knowledge_engines()
engine_id_set = {e.get('plugin_id') for e in engines}
except WorkspaceNotFoundError:
raise
except Exception:
engine_id_set = set()
@@ -189,12 +245,13 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'INSERT INTO knowledge_bases '
'(uuid, name, description, emoji, created_at, updated_at, '
'(uuid, workspace_uuid, name, description, emoji, created_at, updated_at, '
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
'VALUES (:uuid, :workspace_uuid, :name, :description, :emoji, :created_at, :updated_at, '
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
).bindparams(
uuid=kb_uuid,
workspace_uuid=execution_context.workspace_uuid,
name=name,
description=description,
emoji=emoji,
@@ -207,6 +264,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
)
)
await self.ap.plugin_connector.require_workspace_context(execution_context)
try:
config = {'embedding_model_uuid': embedding_model_uuid}
await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config)
@@ -268,12 +326,13 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'INSERT INTO knowledge_bases '
'(uuid, name, description, emoji, created_at, updated_at, '
'(uuid, workspace_uuid, name, description, emoji, created_at, updated_at, '
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
'VALUES (:uuid, :workspace_uuid, :name, :description, :emoji, :created_at, :updated_at, '
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
).bindparams(
uuid=kb_uuid,
workspace_uuid=execution_context.workspace_uuid,
name=name,
description=description,
emoji=emoji,
@@ -294,6 +353,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
warnings.append(warning)
task_context.trace(warning)
else:
await self.ap.plugin_connector.require_workspace_context(execution_context)
try:
await self.ap.plugin_connector.rag_on_kb_create(
external_plugin_id, kb_uuid, creation_settings_dict
@@ -307,16 +367,23 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
await self.ap.rag_mgr.load_knowledge_bases_from_db()
# Step 5: Clear migration flag
await self._set_migration_flag('false')
await self._set_migration_flag(execution_context, 'false')
task_context.trace('RAG migration completed.', action='done')
if warnings:
task_context.trace(f'Completed with {len(warnings)} warning(s).')
async def initialize(self) -> None:
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
needed = await self._get_migration_flag()
@self.route(
'/status',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
execution_context = ExecutionContext.from_request(request_context)
execution_context = await self._require_local_migration_context(execution_context)
needed = await self._get_migration_flag(execution_context)
internal_kb_count = 0
external_kb_count = 0
@@ -342,9 +409,16 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
}
)
@self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
needed = await self._get_migration_flag()
@self.route(
'/execute',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
execution_context = ExecutionContext.from_request(request_context)
execution_context = await self._require_local_migration_context(execution_context)
needed = await self._get_migration_flag(execution_context)
if not needed:
return self.http_status(400, -1, 'RAG migration is not needed')
@@ -353,20 +427,34 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin),
self._execute_rag_migration(
execution_context,
task_context=ctx,
install_plugin=install_plugin,
),
kind='rag-migration',
name='rag-migration-execute',
label='Migrating knowledge bases to plugin architecture',
context=ctx,
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
)
return self.success(data={'task_id': wrapper.id})
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
needed = await self._get_migration_flag()
@self.route(
'/dismiss',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
execution_context = ExecutionContext.from_request(request_context)
execution_context = await self._require_local_migration_context(execution_context)
needed = await self._get_migration_flag(execution_context)
if not needed:
return self.http_status(400, -1, 'RAG migration is not needed')
await self._set_migration_flag('false')
await self._set_migration_flag(execution_context, 'false')
return self.success()
@@ -1,16 +1,24 @@
import quart
from ....authz import Permission
from ....context import RequestContext
from ... import group
@group.group_class('parsers', '/api/v1/knowledge/parsers')
class ParsersRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def list_parsers() -> quart.Response:
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def list_parsers(request_context: RequestContext) -> quart.Response:
"""List all available parsers from plugins.
Optional query parameter `mime_type` to filter parsers by supported MIME type.
"""
mime_type = quart.request.args.get('mime_type')
parsers = await self.ap.knowledge_service.list_parsers(mime_type)
parsers = await self.ap.knowledge_service.list_parsers(request_context, mime_type)
return self.success(data={'parsers': parsers})
@@ -3,14 +3,23 @@ from __future__ import annotations
import quart
from ...authz import Permission
from ...context import RequestContext
from .. import group
@group.group_class('logs', '/api/v1/logs')
class LogsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
@self.route('', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def _(request_context: RequestContext) -> str:
# The process log is instance-global. It is safe to expose only in
# the OSS singleton Workspace; SaaS must use Workspace-scoped
# observability records instead of leaking another tenant's lines.
await self.ap.workspace_service.get_local_execution_binding(
request_context.workspace_uuid,
expected_generation=request_context.placement_generation,
)
start_page_number = int(quart.request.args.get('start_page_number', 0))
start_offset = int(quart.request.args.get('start_offset', 0))
@@ -3,6 +3,8 @@ from __future__ import annotations
import datetime
import quart
from ...authz import Permission
from ...context import RequestContext
from .. import group
@@ -24,8 +26,8 @@ def parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None:
@group.group_class('monitoring', '/api/v1/monitoring')
class MonitoringRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/overview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_overview() -> str:
@self.route('/overview', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_overview(request_context: RequestContext) -> str:
"""Get overview metrics"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -38,6 +40,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
metrics = await self.ap.monitoring_service.get_overview_metrics(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -46,8 +49,8 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=metrics)
@self.route('/token-statistics', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_token_statistics() -> str:
@self.route('/token-statistics', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_token_statistics(request_context: RequestContext) -> str:
"""Get detailed token usage statistics (summary, per-model, timeseries)."""
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
@@ -61,6 +64,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
stats = await self.ap.monitoring_service.get_token_statistics(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -70,8 +74,8 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=stats)
@self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_messages() -> str:
@self.route('/messages', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_messages(request_context: RequestContext) -> str:
"""Get message logs"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -87,6 +91,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
messages, total = await self.ap.monitoring_service.get_messages(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
session_ids=session_ids if session_ids else None,
@@ -105,8 +110,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/llm-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_llm_calls() -> str:
@self.route('/llm-calls', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_llm_calls(request_context: RequestContext) -> str:
"""Get LLM call records"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -121,6 +126,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
llm_calls, total = await self.ap.monitoring_service.get_llm_calls(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -138,8 +144,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/tool-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_tool_calls() -> str:
@self.route('/tool-calls', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_tool_calls(request_context: RequestContext) -> str:
"""Get tool call records"""
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
@@ -153,6 +159,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
tool_calls, total = await self.ap.monitoring_service.get_tool_calls(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
session_ids=session_ids if session_ids else None,
@@ -171,8 +178,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_embedding_calls() -> str:
@self.route('/embedding-calls', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_embedding_calls(request_context: RequestContext) -> str:
"""Get embedding call records"""
# Parse query parameters
start_time_str = quart.request.args.get('startTime')
@@ -186,6 +193,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls(
request_context,
start_time=start_time,
end_time=end_time,
knowledge_base_id=knowledge_base_id if knowledge_base_id else None,
@@ -202,8 +210,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_sessions() -> str:
@self.route('/sessions', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_sessions(request_context: RequestContext) -> str:
"""Get session information"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -224,6 +232,7 @@ class MonitoringRouterGroup(group.RouterGroup):
is_active = is_active_str.lower() == 'true'
sessions, total = await self.ap.monitoring_service.get_sessions(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -242,8 +251,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_errors() -> str:
@self.route('/errors', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_errors(request_context: RequestContext) -> str:
"""Get error logs"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -258,6 +267,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
errors, total = await self.ap.monitoring_service.get_errors(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -275,8 +285,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/data', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_all_data() -> str:
@self.route('/data', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_all_data(request_context: RequestContext) -> str:
"""Get all monitoring data in a single request"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -291,6 +301,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get overview metrics
overview = await self.ap.monitoring_service.get_overview_metrics(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -299,6 +310,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get messages
messages, messages_total = await self.ap.monitoring_service.get_messages(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -309,6 +321,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get LLM calls
llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -319,6 +332,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get tool calls
tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -329,6 +343,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get sessions
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -340,6 +355,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get errors
errors, errors_total = await self.ap.monitoring_service.get_errors(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -350,6 +366,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get embedding calls
embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls(
request_context,
start_time=start_time,
end_time=end_time,
limit=limit,
@@ -376,27 +393,27 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/sessions/<session_id>/analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_session_analysis(session_id: str) -> str:
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
"""Get detailed analysis for a specific session"""
analysis = await self.ap.monitoring_service.get_session_analysis(session_id)
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
# Always return success with the analysis data
# The frontend will handle the 'found: false' case
return self.success(data=analysis)
@self.route('/messages/<message_id>/details', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_message_details(message_id: str) -> str:
@self.route('/messages/<message_id>/details', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_message_details(message_id: str, request_context: RequestContext) -> str:
"""Get detailed information for a specific message"""
details = await self.ap.monitoring_service.get_message_details(message_id)
details = await self.ap.monitoring_service.get_message_details(request_context, message_id)
if not details.get('found'):
return self.error(message=f'Message {message_id} not found', code=404)
return self.success(data=details)
@self.route('/export', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def export_data() -> tuple[str, int]:
@self.route('/export', methods=['GET'], permission=Permission.DATA_EXPORT)
async def export_data(request_context: RequestContext) -> tuple[str, int]:
"""Export monitoring data as CSV"""
# Parse query parameters
export_type = quart.request.args.get('type', 'messages')
@@ -413,6 +430,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get data based on export type
if export_type == 'messages':
data = await self.ap.monitoring_service.export_messages(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -437,6 +455,7 @@ class MonitoringRouterGroup(group.RouterGroup):
]
elif export_type == 'llm-calls':
data = await self.ap.monitoring_service.export_llm_calls(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -463,6 +482,7 @@ class MonitoringRouterGroup(group.RouterGroup):
]
elif export_type == 'embedding-calls':
data = await self.ap.monitoring_service.export_embedding_calls(
request_context,
start_time=start_time,
end_time=end_time,
limit=limit,
@@ -485,6 +505,7 @@ class MonitoringRouterGroup(group.RouterGroup):
]
elif export_type == 'errors':
data = await self.ap.monitoring_service.export_errors(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -506,6 +527,7 @@ class MonitoringRouterGroup(group.RouterGroup):
]
elif export_type == 'sessions':
data = await self.ap.monitoring_service.export_sessions(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -527,6 +549,7 @@ class MonitoringRouterGroup(group.RouterGroup):
]
elif export_type == 'feedback':
data = await self.ap.monitoring_service.export_feedback(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -581,8 +604,8 @@ class MonitoringRouterGroup(group.RouterGroup):
return response, 200
@self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_feedback_stats() -> str:
@self.route('/feedback/stats', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_feedback_stats(request_context: RequestContext) -> str:
"""Get feedback statistics"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -595,6 +618,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
stats = await self.ap.monitoring_service.get_feedback_stats(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -603,8 +627,8 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=stats)
@self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_feedback() -> str:
@self.route('/feedback', methods=['GET'], permission=Permission.AUDIT_VIEW)
async def get_feedback(request_context: RequestContext) -> str:
"""Get feedback list"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -623,6 +647,7 @@ class MonitoringRouterGroup(group.RouterGroup):
feedback_type = int(feedback_type_str) if feedback_type_str else None
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
feedback_type=feedback_type,
@@ -21,9 +21,10 @@ import quart
from ... import group
from ......utils import paths
from ......platform.sources.websocket_manager import is_valid_session_id, ws_connection_manager
from ......platform.sources.websocket_manager import WebSocketScope, is_valid_session_id, ws_connection_manager
logger = logging.getLogger(__name__)
_AUTH_TIMEOUT_SECONDS = 10.0
# Cache the widget template content
_widget_template_cache: str | None = None
@@ -58,37 +59,31 @@ def _get_logo_bytes() -> bytes:
class EmbedRouterGroup(group.RouterGroup):
# -- helpers -------------------------------------------------------------
def _resolve_bot(self, bot_uuid: str):
async def _resolve_bot(self, bot_uuid: str):
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
Returns ``(None, None)`` when the bot does not exist, is not a
``web_page_bot``, is disabled, or has no pipeline bound.
"""
for bot in self.ap.platform_mgr.bots:
if (
bot.bot_entity.uuid == bot_uuid
and bot.bot_entity.adapter == 'web_page_bot'
and bot.bot_entity.enable
and bot.bot_entity.use_pipeline_uuid
):
return bot, bot.bot_entity.use_pipeline_uuid
bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
if (
bot is not None
and bot.bot_entity.adapter == 'web_page_bot'
and bot.bot_entity.enable
and bot.bot_entity.use_pipeline_uuid
):
return bot, bot.bot_entity.use_pipeline_uuid
return None, None
def _get_bot_config(self, bot_uuid: str) -> dict:
for bot in self.ap.platform_mgr.bots:
if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
return bot.bot_entity.adapter_config
return {}
@staticmethod
def _get_bot_config(runtime_bot) -> dict:
return runtime_bot.bot_entity.adapter_config
async def _verify_session_token(self, request, bot_uuid: str) -> bool:
config = self._get_bot_config(bot_uuid)
def _verify_session_token_value(self, token: str, runtime_bot) -> bool:
config = self._get_bot_config(runtime_bot)
secret = config.get('turnstile_secret_key', '')
if not secret:
return True
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return False
token = auth_header[7:]
try:
ts_str, mac = token.split('.', 1)
ts = float(ts_str)
@@ -99,6 +94,50 @@ class EmbedRouterGroup(group.RouterGroup):
except Exception:
return False
async def _verify_session_token(self, request, runtime_bot) -> bool:
auth_header = request.headers.get('Authorization', '')
token = auth_header[7:] if auth_header.startswith('Bearer ') else ''
return self._verify_session_token_value(token, runtime_bot)
async def _authenticate_websocket(self, runtime_bot) -> None:
"""Require the embed session token as the first WebSocket frame."""
raw_message = await asyncio.wait_for(quart.websocket.receive(), timeout=_AUTH_TIMEOUT_SECONDS)
payload = json.loads(raw_message)
if not isinstance(payload, dict) or payload.get('type') != 'authenticate':
raise ValueError('Authentication is required')
token = str(payload.get('token') or '')
if not self._verify_session_token_value(token, runtime_bot):
raise ValueError('Authentication is required')
async def _assert_execution_active(self, runtime_bot) -> None:
context = runtime_bot.execution_context
await self.ap.workspace_service.get_execution_binding(
context.workspace_uuid,
expected_generation=context.placement_generation,
)
async def _resolve_connected_bot(self, owner_bot, pipeline_uuid: str):
"""Re-resolve mutable bot state before every public message."""
current_bot, current_pipeline_uuid = await self._resolve_bot(owner_bot.bot_entity.uuid)
if current_bot is None or current_pipeline_uuid != pipeline_uuid:
raise RuntimeError('Bot is unavailable')
owner_context = owner_bot.execution_context
current_context = current_bot.execution_context
if (
current_context.instance_uuid,
current_context.workspace_uuid,
current_context.placement_generation,
) != (
owner_context.instance_uuid,
owner_context.workspace_uuid,
owner_context.placement_generation,
):
raise RuntimeError('Bot is unavailable')
await self._assert_execution_active(current_bot)
return current_bot
# -- routes --------------------------------------------------------------
async def initialize(self) -> None:
@@ -106,7 +145,7 @@ class EmbedRouterGroup(group.RouterGroup):
async def verify_turnstile(bot_uuid: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
try:
@@ -115,7 +154,7 @@ class EmbedRouterGroup(group.RouterGroup):
if not token:
return self.http_status(400, -1, 'Token is required')
config = self._get_bot_config(bot_uuid)
config = self._get_bot_config(runtime_bot)
secret = config.get('turnstile_secret_key', '')
if not secret:
ts = time.time()
@@ -146,7 +185,7 @@ class EmbedRouterGroup(group.RouterGroup):
"""Serve the embed widget JavaScript with injected configuration."""
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
return quart.Response(
'// Bot not found or not available', status=404, content_type='application/javascript'
@@ -164,7 +203,7 @@ class EmbedRouterGroup(group.RouterGroup):
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
base_url = quart.request.host_url.rstrip('/')
config = self._get_bot_config(bot_uuid)
config = self._get_bot_config(runtime_bot)
site_key = config.get('turnstile_site_key', '')
locale = config.get('language', 'en_US') or 'en_US'
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
@@ -194,10 +233,10 @@ class EmbedRouterGroup(group.RouterGroup):
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
if not await self._verify_session_token(quart.request, bot_uuid):
if not await self._verify_session_token(quart.request, runtime_bot):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
if session_type not in ['person', 'group']:
@@ -207,7 +246,8 @@ class EmbedRouterGroup(group.RouterGroup):
if not is_valid_session_id(session_id):
return self.http_status(400, -1, 'Valid session_id is required')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
websocket_adapter = proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
@@ -222,10 +262,10 @@ class EmbedRouterGroup(group.RouterGroup):
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
if not await self._verify_session_token(quart.request, bot_uuid):
if not await self._verify_session_token(quart.request, runtime_bot):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
if session_type not in ['person', 'group']:
@@ -235,7 +275,8 @@ class EmbedRouterGroup(group.RouterGroup):
if not is_valid_session_id(session_id):
return self.http_status(400, -1, 'Valid session_id is required')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
websocket_adapter = proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
@@ -250,10 +291,10 @@ class EmbedRouterGroup(group.RouterGroup):
async def submit_feedback(bot_uuid: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
if not await self._verify_session_token(quart.request, bot_uuid):
if not await self._verify_session_token(quart.request, runtime_bot):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
data = await quart.request.get_json()
@@ -266,6 +307,7 @@ class EmbedRouterGroup(group.RouterGroup):
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
await self.ap.monitoring_service.record_feedback(
runtime_bot.execution_context,
feedback_id=feedback_id,
feedback_type=feedback_type,
bot_id=runtime_bot.bot_entity.uuid,
@@ -286,11 +328,12 @@ class EmbedRouterGroup(group.RouterGroup):
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
async def embed_websocket_connect(bot_uuid: str):
"""WebSocket connection for embed widget, keyed by bot_uuid."""
await quart.websocket.accept()
if not _is_valid_uuid(bot_uuid):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
return
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
return
@@ -307,14 +350,23 @@ class EmbedRouterGroup(group.RouterGroup):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Valid session_id is required'}))
return
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
try:
await self._authenticate_websocket(runtime_bot)
await self._assert_execution_active(runtime_bot)
except Exception:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
return
try:
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
websocket_adapter = proxy_bot.adapter
if not websocket_adapter:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
return
connection = await ws_connection_manager.add_connection(
websocket=quart.websocket._get_current_object(),
scope=WebSocketScope.from_context(runtime_bot.execution_context),
pipeline_uuid=pipeline_uuid,
session_type=session_type,
session_id=session_id,
@@ -338,7 +390,9 @@ class EmbedRouterGroup(group.RouterGroup):
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
)
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
receive_task = asyncio.create_task(
self._handle_receive(connection, websocket_adapter, runtime_bot, pipeline_uuid)
)
send_task = asyncio.create_task(self._handle_send(connection))
try:
@@ -357,7 +411,7 @@ class EmbedRouterGroup(group.RouterGroup):
# -- WebSocket receive/send helpers --------------------------------------
async def _handle_receive(self, connection, websocket_adapter, owner_bot):
async def _handle_receive(self, connection, websocket_adapter, owner_bot, pipeline_uuid: str):
try:
while connection.is_active:
message = await quart.websocket.receive()
@@ -372,7 +426,12 @@ class EmbedRouterGroup(group.RouterGroup):
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
)
elif message_type == 'message':
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
try:
current_bot = await self._resolve_connected_bot(owner_bot, pipeline_uuid)
except Exception:
await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'})
break
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot)
elif message_type == 'disconnect':
break
@@ -386,7 +445,7 @@ class EmbedRouterGroup(group.RouterGroup):
async def _handle_send(self, connection):
try:
while connection.is_active:
while connection.is_active or not connection.send_queue.empty():
try:
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
await quart.websocket.send(json.dumps(message))
@@ -2,120 +2,156 @@ from __future__ import annotations
import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ....service.secrets import redact_secrets
from ... import group
@group.group_class('pipelines', '/api/v1/pipelines')
class PipelinesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
sort_by = quart.request.args.get('sort_by', 'created_at')
sort_order = quart.request.args.get('sort_order', 'DESC')
return self.success(
data={'pipelines': await self.ap.pipeline_service.get_pipelines(sort_by, sort_order)}
)
elif quart.request.method == 'POST':
json_data = await quart.request.json
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data)
return self.success(data={'uuid': pipeline_uuid})
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()})
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
sort_by = quart.request.args.get('sort_by', 'created_at')
sort_order = quart.request.args.get('sort_order', 'DESC')
include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
return self.success(
data={
'pipelines': await self.ap.pipeline_service.get_pipelines(
request_context,
sort_by,
sort_order,
include_secret=include_secret,
)
}
)
@self.route(
'/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
'',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(pipeline_uuid: str) -> str:
if quart.request.method == 'GET':
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
async def _(request_context: RequestContext) -> str:
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
return self.success(data={'uuid': pipeline_uuid})
if pipeline is None:
return self.http_status(404, -1, 'pipeline not found')
@self.route(
'/_/metadata',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata(request_context)})
return self.success(data={'pipeline': pipeline})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
@self.route(
'/<pipeline_uuid>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
pipeline = await self.ap.pipeline_service.get_pipeline(
request_context,
pipeline_uuid,
include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
)
if pipeline is None:
return self.http_status(404, -1, 'pipeline not found')
return self.success(data={'pipeline': pipeline})
await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data)
@self.route(
'/<pipeline_uuid>',
methods=['PUT', 'DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
if quart.request.method == 'PUT':
try:
await self.ap.pipeline_service.update_pipeline(
request_context,
pipeline_uuid,
await quart.request.json,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
else:
await self.ap.pipeline_service.delete_pipeline(request_context, pipeline_uuid)
return self.success()
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.pipeline_service.delete_pipeline(pipeline_uuid)
return self.success()
@self.route('/<pipeline_uuid>/copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(pipeline_uuid: str) -> str:
@self.route(
'/<pipeline_uuid>/copy',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
try:
new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid)
new_uuid = await self.ap.pipeline_service.copy_pipeline(request_context, pipeline_uuid)
return self.success(data={'uuid': new_uuid})
except ValueError as e:
return self.http_status(404, -1, str(e))
return self.http_status(400, -1, str(e))
@self.route(
'/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
'/<pipeline_uuid>/extensions',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(pipeline_uuid: str) -> str:
if quart.request.method == 'GET':
# Get current extensions and available plugins
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
if pipeline is None:
return self.http_status(404, -1, 'pipeline not found')
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
pipeline = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
if pipeline is None:
return self.http_status(404, -1, 'pipeline not found')
# Only include plugins with pipeline-related components (Command, EventListener, Tool)
# Plugins that only have KnowledgeEngine components are not suitable for pipeline extensions
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
if self.ap.plugin_connector.is_enable_plugin:
await self.ap.plugin_connector.require_workspace_context(request_context)
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
mcp_servers = await self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True)
available_skills = await self.ap.skill_service.list_skills(request_context)
extensions_prefs = pipeline.get('extensions_preferences', {})
return self.success(
data={
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
'enable_all_skills': extensions_prefs.get('enable_all_skills', True),
'bound_plugins': extensions_prefs.get('plugins', []),
'available_plugins': redact_secrets(plugins),
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
'available_mcp_servers': mcp_servers,
'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
'mcp_resource_agent_read_enabled': extensions_prefs.get('mcp_resource_agent_read_enabled', True),
'bound_skills': extensions_prefs.get('skills', []),
'available_skills': available_skills,
}
)
# Get available skills
available_skills = await self.ap.skill_service.list_skills()
extensions_prefs = pipeline.get('extensions_preferences', {})
return self.success(
data={
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
'enable_all_skills': extensions_prefs.get('enable_all_skills', True),
'bound_plugins': extensions_prefs.get('plugins', []),
'available_plugins': plugins,
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
'available_mcp_servers': mcp_servers,
'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
'mcp_resource_agent_read_enabled': extensions_prefs.get(
'mcp_resource_agent_read_enabled', True
),
'bound_skills': extensions_prefs.get('skills', []),
'available_skills': available_skills,
}
)
elif quart.request.method == 'PUT':
# Update bound plugins and MCP servers for this pipeline
json_data = await quart.request.json
enable_all_plugins = json_data.get('enable_all_plugins', True)
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
enable_all_skills = json_data.get('enable_all_skills', True)
bound_plugins = json_data.get('bound_plugins', [])
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
bound_skills = json_data.get('bound_skills', [])
bound_mcp_resources = json_data.get('bound_mcp_resources')
mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled')
await self.ap.pipeline_service.update_pipeline_extensions(
pipeline_uuid,
bound_plugins,
bound_mcp_servers,
enable_all_plugins,
enable_all_mcp_servers,
bound_skills=bound_skills,
enable_all_skills=enable_all_skills,
bound_mcp_resources=bound_mcp_resources,
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
)
return self.success()
@self.route(
'/<pipeline_uuid>/extensions',
methods=['PUT'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
await self.ap.pipeline_service.update_pipeline_extensions(
request_context,
pipeline_uuid,
json_data.get('bound_plugins', []),
json_data.get('bound_mcp_servers', []),
json_data.get('enable_all_plugins', True),
json_data.get('enable_all_mcp_servers', True),
bound_skills=json_data.get('bound_skills', []),
enable_all_skills=json_data.get('enable_all_skills', True),
bound_mcp_resources=json_data.get('bound_mcp_resources'),
mcp_resource_agent_read_enabled=json_data.get('mcp_resource_agent_read_enabled'),
)
return self.success()
@@ -1,64 +1,157 @@
"""WebSocket聊天路由 - 支持双向实时通信"""
"""Authenticated dashboard WebSocket chat routes."""
from __future__ import annotations
import asyncio
import datetime
import json
import logging
import uuid
import quart
from ....authz import Permission, permissions_for_role, require_permission
from ....context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from ... import group
from ......platform.sources.websocket_manager import ws_connection_manager
from ......platform.sources.websocket_manager import WebSocketScope, ws_connection_manager
logger = logging.getLogger(__name__)
_AUTH_TIMEOUT_SECONDS = 10.0
@group.group_class('websocket_chat', '/api/v1/pipelines/<pipeline_uuid>/ws')
class WebSocketChatRouterGroup(group.RouterGroup):
async def _authenticate_websocket(self) -> tuple[RequestContext, str]:
"""Authenticate the first dashboard WebSocket message.
Browsers cannot attach the normal Authorization/X-Workspace-Id headers
to a WebSocket handshake. The client therefore sends one auth frame
immediately after opening the socket; no connection is registered and
no runtime object is resolved before this method succeeds.
"""
raw_message = await asyncio.wait_for(quart.websocket.receive(), timeout=_AUTH_TIMEOUT_SECONDS)
payload = json.loads(raw_message)
if not isinstance(payload, dict) or payload.get('type') != 'authenticate':
raise ValueError('Authentication is required')
token = str(payload.get('token') or '').strip()
workspace_uuid = str(payload.get('workspace_uuid') or '').strip()
if not token or not workspace_uuid:
raise ValueError('Authentication is required')
account, _ = await self._authenticate_account(token)
account_uuid = getattr(account, 'uuid', None)
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
if not isinstance(account_uuid, str) or collaboration_service is None:
raise ValueError('Workspace authentication is unavailable')
access = await collaboration_service.resolve_account_workspace(account_uuid, workspace_uuid)
request_context = RequestContext(
instance_uuid=access.execution.instance_uuid,
placement_generation=access.execution.placement_generation,
request_id=quart.websocket.headers.get('X-Request-Id') or str(uuid.uuid4()),
auth_type=group.AuthType.USER_TOKEN.value,
principal=PrincipalContext(
principal_type=PrincipalType.ACCOUNT,
account_uuid=account_uuid,
),
workspace=WorkspaceContext(
workspace_uuid=access.workspace.uuid,
membership_uuid=access.membership.uuid,
role=access.membership.role,
permissions=permissions_for_role(access.membership.role),
membership_revision=access.membership.projection_revision,
),
)
require_permission(request_context, Permission.RUNTIME_OPERATE)
return request_context, token
async def _revalidate_websocket_authorization(
self,
request_context: RequestContext,
token: str,
) -> None:
"""Recheck revocable account, membership, permission, and placement state."""
account, _ = await self._authenticate_account(token)
account_uuid = getattr(account, 'uuid', None)
if account_uuid != request_context.account_uuid:
raise ValueError('WebSocket account changed')
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
if collaboration_service is None or not isinstance(account_uuid, str):
raise ValueError('Workspace authentication is unavailable')
access = await collaboration_service.resolve_account_workspace(
account_uuid,
request_context.workspace_uuid,
)
if (
access.workspace.uuid != request_context.workspace_uuid
or access.membership.uuid != request_context.workspace.membership_uuid
or access.membership.projection_revision != request_context.workspace.membership_revision
or access.execution.instance_uuid != request_context.instance_uuid
or access.execution.placement_generation != request_context.placement_generation
):
raise ValueError('WebSocket authorization changed')
current_context = RequestContext(
instance_uuid=access.execution.instance_uuid,
placement_generation=access.execution.placement_generation,
request_id=request_context.request_id,
auth_type=request_context.auth_type,
principal=request_context.principal,
workspace=WorkspaceContext(
workspace_uuid=access.workspace.uuid,
membership_uuid=access.membership.uuid,
role=access.membership.role,
permissions=permissions_for_role(access.membership.role),
membership_revision=access.membership.projection_revision,
),
entitlement_revision=request_context.entitlement_revision,
)
require_permission(current_context, Permission.RUNTIME_OPERATE)
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
pipeline = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
if pipeline is None:
return None
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(request_context)
return proxy_bot.adapter
async def initialize(self) -> None:
# 直接使用 quart_app 注册 WebSocket 路由
@self.quart_app.websocket(self.path + '/connect')
async def websocket_connect(pipeline_uuid: str):
"""
建立WebSocket连接
"""Open one authenticated dashboard debug connection."""
URL参数:
- pipeline_uuid: 流水线UUID
- session_type: 会话类型 (person/group)
"""
await quart.websocket.accept()
try:
# 获取参数 - 在WebSocket上下文中使用 quart.websocket.args
session_type = quart.websocket.args.get('session_type', 'person')
request_context, token = await self._authenticate_websocket()
except Exception:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
return
if session_type not in ['person', 'group']:
await quart.websocket.send(
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
)
session_type = quart.websocket.args.get('session_type', 'person')
if session_type not in ['person', 'group']:
await quart.websocket.send(
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
)
return
try:
websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
if websocket_adapter is None:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Pipeline not found'}))
return
# 获取WebSocket适配器
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
return
# Dashboard pipeline-debug sessions must always run under the
# built-in websocket_proxy_bot identity. We deliberately do NOT
# resolve a web_page_bot owner here — even if one is bound to
# the same pipeline, debug requests must not be attributed to
# it. The embed widget path (`/api/v1/embed/<bot>/ws/connect`)
# is the one that carries the page-bot identity.
# 注册连接
connection = await ws_connection_manager.add_connection(
websocket=quart.websocket._get_current_object(),
scope=WebSocketScope.from_context(request_context),
pipeline_uuid=pipeline_uuid,
session_type=session_type,
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
)
# 发送连接成功消息
await quart.websocket.send(
json.dumps(
{
@@ -72,182 +165,180 @@ class WebSocketChatRouterGroup(group.RouterGroup):
)
logger.debug(
f'WebSocket connection established: {connection.connection_id} '
f'(pipeline={pipeline_uuid}, session_type={session_type})'
f'Dashboard WebSocket connected: {connection.connection_id} '
f'(workspace={connection.workspace_uuid}, pipeline={pipeline_uuid}, '
f'session_type={session_type})'
)
# 创建接收和发送任务
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
receive_task = asyncio.create_task(
self._handle_receive(
connection,
websocket_adapter,
request_context,
token,
)
)
send_task = asyncio.create_task(self._handle_send(connection))
# 等待任务完成
try:
await asyncio.gather(receive_task, send_task)
except Exception as e:
logger.error(f'WebSocket task execution error: {e}')
except Exception as exc:
logger.error(f'WebSocket task execution error: {exc}')
finally:
# 清理连接
await ws_connection_manager.remove_connection(connection.connection_id)
logger.debug(f'WebSocket connection cleaned: {connection.connection_id}')
except Exception as e:
logger.error(f'WebSocket connection error: {e}', exc_info=True)
except Exception:
logger.error('Dashboard WebSocket connection error', exc_info=True)
try:
await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)}))
except:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
except Exception:
pass
@self.route('/messages/<session_type>', methods=['GET'])
async def get_messages(pipeline_uuid: str, session_type: str) -> str:
"""获取消息历史"""
try:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
@self.route(
'/messages/<session_type>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RUNTIME_OPERATE,
)
async def get_messages(
pipeline_uuid: str,
session_type: str,
request_context: RequestContext,
) -> str:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
if websocket_adapter is None:
return self.http_status(404, -1, 'Pipeline not found')
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
return self.success(data={'messages': messages})
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
@self.route(
'/reset/<session_type>',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RUNTIME_OPERATE,
)
async def reset_session(
pipeline_uuid: str,
session_type: str,
request_context: RequestContext,
) -> str:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
if websocket_adapter is None:
return self.http_status(404, -1, 'Pipeline not found')
websocket_adapter.reset_session(pipeline_uuid, session_type)
return self.success(data={'message': 'Session reset successfully'})
return self.success(data={'messages': messages})
@self.route(
'/connections',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RUNTIME_OPERATE,
)
async def get_connections(pipeline_uuid: str, request_context: RequestContext) -> str:
if await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid) is None:
return self.http_status(404, -1, 'Pipeline not found')
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
@self.route('/reset/<session_type>', methods=['POST'])
async def reset_session(pipeline_uuid: str, session_type: str) -> str:
"""重置会话"""
try:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
websocket_adapter.reset_session(pipeline_uuid, session_type)
return self.success(data={'message': 'Session reset successfully'})
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
@self.route('/connections', methods=['GET'])
async def get_connections(pipeline_uuid: str) -> str:
"""获取当前连接统计"""
try:
stats = ws_connection_manager.get_stats()
connections = await ws_connection_manager.get_connections_by_pipeline(pipeline_uuid)
return self.success(
data={
'stats': stats,
'connections': [
{
'connection_id': conn.connection_id,
'session_type': conn.session_type,
'created_at': conn.created_at.isoformat(),
'last_active': conn.last_active.isoformat(),
'is_active': conn.is_active,
}
for conn in connections
],
}
)
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
@self.route('/broadcast', methods=['POST'])
async def broadcast_message(pipeline_uuid: str) -> str:
"""向所有连接广播消息(后端主动推送)"""
try:
data = await quart.request.get_json()
message = data.get('message')
if not message:
return self.http_status(400, -1, 'message is required')
# 广播消息
broadcast_data = {
'type': 'broadcast',
'message': message,
'timestamp': datetime.datetime.now().isoformat(),
scope = WebSocketScope.from_context(request_context)
stats = ws_connection_manager.get_stats(scope=scope)
connections = await ws_connection_manager.get_connections_by_pipeline(
pipeline_uuid,
scope=scope,
)
return self.success(
data={
'stats': stats,
'connections': [
{
'connection_id': connection.connection_id,
'session_type': connection.session_type,
'created_at': connection.created_at.isoformat(),
'last_active': connection.last_active.isoformat(),
'is_active': connection.is_active,
}
for connection in connections
],
}
)
await ws_connection_manager.broadcast_to_pipeline(pipeline_uuid, broadcast_data)
@self.route(
'/broadcast',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RUNTIME_OPERATE,
)
async def broadcast_message(pipeline_uuid: str, request_context: RequestContext) -> str:
if await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid) is None:
return self.http_status(404, -1, 'Pipeline not found')
return self.success(data={'message': 'Broadcast sent successfully'})
data = await quart.request.get_json()
message = data.get('message')
if not message:
return self.http_status(400, -1, 'message is required')
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
broadcast_data = {
'type': 'broadcast',
'message': message,
'timestamp': datetime.datetime.now().isoformat(),
}
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
broadcast_data,
scope=WebSocketScope.from_context(request_context),
)
return self.success(data={'message': 'Broadcast sent successfully'})
async def _handle_receive(self, connection, websocket_adapter):
"""处理接收消息的任务"""
async def _handle_receive(
self,
connection,
websocket_adapter,
request_context: RequestContext,
token: str,
):
try:
while connection.is_active:
# 接收消息
message = await quart.websocket.receive()
# 更新活跃时间
await ws_connection_manager.update_activity(connection.connection_id)
try:
data = json.loads(message)
message_type = data.get('type', 'message')
if message_type == 'ping':
# 心跳响应
await connection.send_queue.put(
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
)
elif message_type == 'message':
# 处理用户消息
logger.debug(f'收到消息: {data} from {connection.connection_id}')
# 处理消息(不等待响应,响应会通过broadcast异步发送)
# owner_bot is intentionally NOT passed: the dashboard
# debug WebSocket must always run under the proxy bot,
# never under a coincidentally-bound web_page_bot.
try:
await self._revalidate_websocket_authorization(request_context, token)
except Exception:
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
break
await websocket_adapter.handle_websocket_message(connection, data)
elif message_type == 'disconnect':
# 客户端主动断开
logger.debug(f'Client disconnected: {connection.connection_id}')
break
else:
logger.warning(f'Unknown message type: {message_type}')
logger.warning(f'Unknown WebSocket message type: {message_type}')
except json.JSONDecodeError:
logger.error(f'Invalid JSON message: {message}')
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
except Exception as e:
logger.error(f'Receive message error: {e}', exc_info=True)
except Exception:
logger.error('Dashboard WebSocket receive error', exc_info=True)
finally:
connection.is_active = False
async def _handle_send(self, connection):
"""处理发送消息的任务"""
try:
while connection.is_active:
# 从队列获取消息
while connection.is_active or not connection.send_queue.empty():
try:
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
# 发送消息
await quart.websocket.send(json.dumps(message))
except asyncio.TimeoutError:
# 超时继续循环
continue
except Exception as e:
logger.error(f'Send message error: {e}', exc_info=True)
except Exception:
logger.error('Dashboard WebSocket send error', exc_info=True)
finally:
connection.is_active = False
@@ -1,9 +1,75 @@
import quart
import mimetypes
import asyncio
from ... import group
import dataclasses
import mimetypes
import quart
from langbot.pkg.api.http.authz import Permission
from langbot.pkg.api.http.context import RequestContext
from langbot.pkg.utils import importutil
from ... import group
@dataclasses.dataclass(frozen=True, slots=True)
class _AdapterSessionScope:
"""Immutable tenant and principal binding for a credential exchange."""
instance_uuid: str
workspace_uuid: str
placement_generation: int
principal_type: str
account_uuid: str | None
api_key_uuid: str | None
@classmethod
def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope':
principal = request_context.principal
return cls(
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
principal_type=principal.principal_type.value,
account_uuid=principal.account_uuid,
api_key_uuid=principal.api_key_uuid,
)
def matches(self, request_context: RequestContext) -> bool:
"""Return whether a request is from the exact initiating tenant principal."""
return self == self.from_request_context(request_context)
def _bind_session_scope(session: dict, request_context: RequestContext) -> None:
session['scope'] = _AdapterSessionScope.from_request_context(request_context)
def _get_owned_session(
sessions: dict[str, dict],
session_id: str,
request_context: RequestContext,
) -> dict | None:
"""Resolve a session without revealing sessions owned by another scope."""
session = sessions.get(session_id)
scope = session.get('scope') if session is not None else None
if not isinstance(scope, _AdapterSessionScope) or not scope.matches(request_context):
return None
return session
def _pop_owned_session(
sessions: dict[str, dict],
session_id: str,
request_context: RequestContext,
) -> dict | None:
"""Remove an owned session without allowing cross-scope cancellation."""
session = _get_owned_session(sessions, session_id, request_context)
if session is None:
return None
return sessions.pop(session_id, None)
def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str:
"""Decrypt the AppSecret returned by the QQ Official QR binding endpoint.
@@ -84,8 +150,8 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/lark/create-app', methods=['POST'])
async def _() -> str:
@self.route('/lark/create-app', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
async def _(request_context: RequestContext) -> str:
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
import uuid
import time
@@ -106,6 +172,7 @@ class AdaptersRouterGroup(group.RouterGroup):
'error': None,
'created_at': time.time(),
}
_bind_session_scope(session, request_context)
_create_app_sessions[session_id] = session
def on_qr_code(info):
@@ -160,10 +227,15 @@ class AdaptersRouterGroup(group.RouterGroup):
}
)
@self.route('/lark/create-app/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
@self.route(
'/lark/create-app/status/<session_id>',
methods=['GET'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(session_id: str, request_context: RequestContext) -> str:
"""Poll registration status."""
session = _create_app_sessions.get(session_id)
_cleanup_expired_sessions()
session = _get_owned_session(_create_app_sessions, session_id, request_context)
if not session:
return self.http_status(404, -1, 'Session not found')
@@ -179,10 +251,16 @@ class AdaptersRouterGroup(group.RouterGroup):
return self.success(data=data)
@self.route('/lark/create-app/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
@self.route(
'/lark/create-app/<session_id>',
methods=['DELETE'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(session_id: str, request_context: RequestContext) -> str:
"""Cancel and clean up a registration session."""
session = _create_app_sessions.pop(session_id, None)
session = _pop_owned_session(_create_app_sessions, session_id, request_context)
if session is None:
return self.http_status(404, -1, 'Session not found')
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
@@ -206,8 +284,8 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/weixin/login', methods=['POST'])
async def _() -> str:
@self.route('/weixin/login', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
async def _(request_context: RequestContext) -> str:
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
import uuid
import time
@@ -229,6 +307,7 @@ class AdaptersRouterGroup(group.RouterGroup):
'error': None,
'created_at': time.time(),
}
_bind_session_scope(session, request_context)
_weixin_login_sessions[session_id] = session
client = OpenClawWeixinClient(
@@ -290,10 +369,15 @@ class AdaptersRouterGroup(group.RouterGroup):
}
)
@self.route('/weixin/login/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
@self.route(
'/weixin/login/status/<session_id>',
methods=['GET'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(session_id: str, request_context: RequestContext) -> str:
"""Poll WeChat login status."""
session = _weixin_login_sessions.get(session_id)
_cleanup_expired_weixin_sessions()
session = _get_owned_session(_weixin_login_sessions, session_id, request_context)
if not session:
return self.http_status(404, -1, 'Session not found')
@@ -317,10 +401,16 @@ class AdaptersRouterGroup(group.RouterGroup):
return self.success(data=data)
@self.route('/weixin/login/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
@self.route(
'/weixin/login/<session_id>',
methods=['DELETE'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(session_id: str, request_context: RequestContext) -> str:
"""Cancel and clean up a WeChat login session."""
session = _weixin_login_sessions.pop(session_id, None)
session = _pop_owned_session(_weixin_login_sessions, session_id, request_context)
if session is None:
return self.http_status(404, -1, 'Session not found')
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
@@ -344,8 +434,8 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/dingtalk/create-app', methods=['POST'])
async def _() -> str:
@self.route('/dingtalk/create-app', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
async def _(request_context: RequestContext) -> str:
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
import uuid
import time
@@ -368,6 +458,7 @@ class AdaptersRouterGroup(group.RouterGroup):
'device_code': None,
'interval': 5,
}
_bind_session_scope(session, request_context)
_dingtalk_sessions[session_id] = session
async def run_device_flow():
@@ -491,11 +582,15 @@ class AdaptersRouterGroup(group.RouterGroup):
}
)
@self.route('/dingtalk/create-app/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
@self.route(
'/dingtalk/create-app/status/<session_id>',
methods=['GET'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(session_id: str, request_context: RequestContext) -> str:
"""Poll DingTalk Device Flow status."""
_cleanup_expired_dingtalk_sessions()
session = _dingtalk_sessions.get(session_id)
session = _get_owned_session(_dingtalk_sessions, session_id, request_context)
if not session:
return self.http_status(404, -1, 'Session not found')
@@ -511,10 +606,16 @@ class AdaptersRouterGroup(group.RouterGroup):
return self.success(data=data)
@self.route('/dingtalk/create-app/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
@self.route(
'/dingtalk/create-app/<session_id>',
methods=['DELETE'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(session_id: str, request_context: RequestContext) -> str:
"""Cancel and clean up a DingTalk Device Flow session."""
session = _dingtalk_sessions.pop(session_id, None)
session = _pop_owned_session(_dingtalk_sessions, session_id, request_context)
if session is None:
return self.http_status(404, -1, 'Session not found')
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
@@ -538,8 +639,8 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/wecombot/create-bot', methods=['POST'])
async def _() -> str:
@self.route('/wecombot/create-bot', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
async def _(request_context: RequestContext) -> str:
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
import uuid
import time
@@ -563,6 +664,7 @@ class AdaptersRouterGroup(group.RouterGroup):
'scode': None,
'task': None,
}
_bind_session_scope(session, request_context)
_wecombot_sessions[session_id] = session
async def run_qr_flow():
@@ -655,11 +757,15 @@ class AdaptersRouterGroup(group.RouterGroup):
}
)
@self.route('/wecombot/create-bot/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
@self.route(
'/wecombot/create-bot/status/<session_id>',
methods=['GET'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(session_id: str, request_context: RequestContext) -> str:
"""Poll WeComBot creation status."""
_cleanup_expired_wecombot_sessions()
session = _wecombot_sessions.get(session_id)
session = _get_owned_session(_wecombot_sessions, session_id, request_context)
if not session:
return self.http_status(404, -1, 'Session not found')
@@ -675,10 +781,16 @@ class AdaptersRouterGroup(group.RouterGroup):
return self.success(data=data)
@self.route('/wecombot/create-bot/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
@self.route(
'/wecombot/create-bot/<session_id>',
methods=['DELETE'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(session_id: str, request_context: RequestContext) -> str:
"""Cancel and clean up a WeComBot creation session."""
session = _wecombot_sessions.pop(session_id, None)
session = _pop_owned_session(_wecombot_sessions, session_id, request_context)
if session is None:
return self.http_status(404, -1, 'Session not found')
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
@@ -702,8 +814,8 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/qqofficial/bind', methods=['POST'])
async def _() -> str:
@self.route('/qqofficial/bind', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
async def _(request_context: RequestContext) -> str:
"""Start QQ Official QR binding. Returns session_id + QR URL.
Flow: generate a local AES-256 key, register it with
@@ -739,6 +851,7 @@ class AdaptersRouterGroup(group.RouterGroup):
'bind_key_bytes': bind_key_bytes,
'interval': 2,
}
_bind_session_scope(session, request_context)
_qqofficial_sessions[session_id] = session
async def run_qr_binding():
@@ -870,11 +983,15 @@ class AdaptersRouterGroup(group.RouterGroup):
}
)
@self.route('/qqofficial/bind/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
@self.route(
'/qqofficial/bind/status/<session_id>',
methods=['GET'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(session_id: str, request_context: RequestContext) -> str:
"""Poll QQ Official QR binding status."""
_cleanup_expired_qqofficial_sessions()
session = _qqofficial_sessions.get(session_id)
session = _get_owned_session(_qqofficial_sessions, session_id, request_context)
if not session:
return self.http_status(404, -1, 'Session not found')
@@ -892,10 +1009,16 @@ class AdaptersRouterGroup(group.RouterGroup):
return self.success(data=data)
@self.route('/qqofficial/bind/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
@self.route(
'/qqofficial/bind/<session_id>',
methods=['DELETE'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(session_id: str, request_context: RequestContext) -> str:
"""Cancel and clean up a QQ Official QR binding session."""
session = _qqofficial_sessions.pop(session_id, None)
session = _pop_owned_session(_qqofficial_sessions, session_id, request_context)
if session is None:
return self.http_status(404, -1, 'Session not found')
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
@@ -1,45 +1,95 @@
import quart
from sqlalchemy.exc import IntegrityError
from ....authz import Permission, has_permission
from ....context import RequestContext
from ... import group
@group.group_class('bots', '/api/v1/platform/bots')
class BotsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
return self.success(data={'bots': await self.ap.bot_service.get_bots()})
elif quart.request.method == 'POST':
json_data = await quart.request.json
bot_uuid = await self.ap.bot_service.create_bot(json_data)
return self.success(data={'uuid': bot_uuid})
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
return self.success(
data={
'bots': await self.ap.bot_service.get_bots(
request_context,
include_secret=include_secret,
)
}
)
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
if quart.request.method == 'GET':
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
if bot is None:
return self.http_status(404, -1, 'bot not found')
return self.success(data={'bot': bot})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.bot_service.update_bot(bot_uuid, json_data)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.bot_service.delete_bot(bot_uuid)
return self.success()
@self.route(
'',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
json_data = await quart.request.json
bot_uuid = await self.ap.bot_service.create_bot(request_context, json_data)
return self.success(data={'uuid': bot_uuid})
@self.route('/<bot_uuid>/logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
@self.route(
'/<bot_uuid>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
bot = await self.ap.bot_service.get_runtime_bot_info(
request_context,
bot_uuid,
include_secret=include_secret,
)
if bot is None:
return self.http_status(404, -1, 'bot not found')
return self.success(data={'bot': bot})
@self.route(
'/<bot_uuid>',
methods=['PUT', 'DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
if quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.bot_service.update_bot(request_context, bot_uuid, json_data)
else:
await self.ap.bot_service.delete_bot(request_context, bot_uuid)
return self.success()
@self.route(
'/<bot_uuid>/logs',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
from_index = json_data.get('from_index', -1)
max_count = json_data.get('max_count', 10)
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
logs, total_count = await self.ap.bot_service.list_event_logs(
request_context, bot_uuid, from_index, max_count
)
return self.success(data={'logs': logs, 'total_count': total_count})
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
async def _(bot_uuid: str) -> str:
@self.route(
'/<bot_uuid>/send_message',
methods=['POST'],
auth_type=group.AuthType.API_KEY,
permission=Permission.RUNTIME_OPERATE,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
target_type = json_data.get('target_type')
target_id = json_data.get('target_id')
@@ -54,37 +104,51 @@ class BotsRouterGroup(group.RouterGroup):
if target_type not in ['person', 'group']:
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
try:
await self.ap.bot_service.send_message(bot_uuid, target_type, target_id, message_chain_data)
return self.success(data={'sent': True})
except Exception as e:
import traceback
traceback.print_exc()
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
# ============ Bot Admins ============
@self.route('/<bot_uuid>/admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
if quart.request.method == 'GET':
admins = await self.ap.bot_service.get_bot_admins(bot_uuid)
return self.success(data={'admins': admins})
elif quart.request.method == 'POST':
json_data = await quart.request.json
launcher_type = json_data.get('launcher_type', '').strip()
launcher_id = str(json_data.get('launcher_id', '')).strip()
if not launcher_type or not launcher_id:
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
try:
admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id)
return self.success(data={'id': admin_id})
except Exception as e:
return self.http_status(409, -1, str(e))
await self.ap.bot_service.send_message(
request_context,
bot_uuid,
target_type,
target_id,
message_chain_data,
)
return self.success(data={'sent': True})
@self.route(
'/<bot_uuid>/admins/<int:admin_id>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
'/<bot_uuid>/admins',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(bot_uuid: str, admin_id: int) -> str:
await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
admins = await self.ap.bot_service.get_bot_admins(request_context, bot_uuid)
return self.success(data={'admins': admins})
@self.route(
'/<bot_uuid>/admins',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
launcher_type = json_data.get('launcher_type', '').strip()
launcher_id = str(json_data.get('launcher_id', '')).strip()
if not launcher_type or not launcher_id:
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
try:
admin_id = await self.ap.bot_service.add_bot_admin(
request_context, bot_uuid, launcher_type, launcher_id
)
return self.success(data={'id': admin_id})
except IntegrityError as e:
return self.http_status(409, -1, str(e))
@self.route(
'/<bot_uuid>/admins/<int:admin_id>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(bot_uuid: str, admin_id: int, request_context: RequestContext) -> str:
await self.ap.bot_service.delete_bot_admin(request_context, bot_uuid, admin_id)
return self.success()
@@ -1,6 +1,8 @@
from __future__ import annotations
import base64
import collections.abc
import copy
import io
import quart
import re
@@ -15,9 +17,139 @@ import sqlalchemy
from .....core import taskmgr
from .....entity.persistence import plugin as persistence_plugin
from ...authz import Permission
from ...context import ExecutionContext, RequestContext
from .. import group
from .....workspace.errors import WorkspaceNotFoundError
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
_SECRET_MASK = '***'
_MISSING_SECRET = object()
_SENSITIVE_CONFIG_NAMES = frozenset(
{
'api_key',
'apikey',
'auth',
'authorization',
'cookie',
'credentials',
'database_url',
'dsn',
'key',
'proxy_authorization',
'set_cookie',
}
)
_SENSITIVE_CONFIG_TOKENS = frozenset(
{
'credential',
'credentials',
'passwd',
'password',
'secret',
'token',
}
)
_SENSITIVE_KEY_QUALIFIERS = frozenset(
{
'access',
'api',
'auth',
'bearer',
'client',
'debug',
'encryption',
'private',
'signing',
}
)
def _normalize_config_key(key: object) -> str:
value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
def _is_sensitive_config_key(key: object) -> bool:
normalized = _normalize_config_key(key)
if normalized in _SENSITIVE_CONFIG_NAMES:
return True
tokens = frozenset(token for token in normalized.split('_') if token)
if tokens & _SENSITIVE_CONFIG_TOKENS:
return True
return 'key' in tokens and bool(tokens & _SENSITIVE_KEY_QUALIFIERS)
def _mask_secret_structure(value):
"""Mask every non-empty leaf while preserving container structure."""
if isinstance(value, dict):
return {key: _mask_secret_structure(item) for key, item in value.items()}
if isinstance(value, list):
return [_mask_secret_structure(item) for item in value]
if isinstance(value, tuple):
return tuple(_mask_secret_structure(item) for item in value)
if value is None or value == '':
return value
return _SECRET_MASK
def redact_plugin_secrets(value):
"""Return a recursively redacted copy of plugin-facing data."""
if isinstance(value, dict):
return {
key: (_mask_secret_structure(item) if _is_sensitive_config_key(key) else redact_plugin_secrets(item))
for key, item in value.items()
}
if isinstance(value, list):
return [redact_plugin_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_plugin_secrets(item) for item in value)
return value
def restore_plugin_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
"""Restore masked leaves from the current config before a management write."""
if sensitive and value == _SECRET_MASK:
if current_value is _MISSING_SECRET:
raise ValueError('Masked plugin secret has no existing value')
return copy.deepcopy(current_value)
if isinstance(value, dict):
current_mapping = current_value if isinstance(current_value, dict) else {}
return {
key: restore_plugin_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
sensitive=sensitive or _is_sensitive_config_key(key),
)
for key, item in value.items()
}
if isinstance(value, list):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return [
restore_plugin_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
]
if isinstance(value, tuple):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return tuple(
restore_plugin_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
)
return value
# Resolve the built-in page SDK JS from the langbot_plugin package
_PAGE_SDK_PATH = None
try:
@@ -148,18 +280,74 @@ class PluginsRouterGroup(group.RouterGroup):
'subdir': subdir,
}
async def _check_extensions_limit(self) -> str | None:
async def _check_extensions_limit(self, request_context: RequestContext) -> str | None:
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
await self.ap.plugin_connector.require_workspace_context(request_context)
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_extensions = limitation.get('max_extensions', -1)
if max_extensions >= 0:
plugins = await self.ap.plugin_connector.list_plugins()
mcp_servers = await self.ap.mcp_service.get_mcp_servers()
mcp_servers = await self.ap.mcp_service.get_mcp_servers(request_context)
total_extensions = len(plugins) + len(mcp_servers)
if total_extensions >= max_extensions:
return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached')
return None
@staticmethod
def _task_scope(request_context: RequestContext) -> dict[str, str | int]:
return {
'instance_uuid': request_context.instance_uuid,
'workspace_uuid': request_context.workspace_uuid,
'placement_generation': request_context.placement_generation,
}
async def _run_fenced_plugin_operation(
self,
execution_context: ExecutionContext,
operation: collections.abc.Callable[[], collections.abc.Awaitable],
):
"""Revalidate a captured task context immediately before Runtime I/O."""
await self.ap.plugin_connector.require_workspace_context(execution_context)
return await operation()
async def _require_public_plugin_runtime_context(self) -> ExecutionContext:
"""Resolve public assets only for the OSS singleton Workspace.
Public image and iframe requests cannot carry the WebUI bearer token.
They therefore remain available for the one-Workspace Core deployment,
but fail closed instead of guessing a Workspace when multi-Workspace
policy is active.
"""
workspace_service = getattr(self.ap, 'workspace_service', None)
policy = getattr(workspace_service, 'policy', None)
if workspace_service is None or policy is None or getattr(policy, 'multi_workspace_enabled', False):
raise WorkspaceNotFoundError('Plugin resource not found')
binding = await workspace_service.get_local_execution_binding()
execution_context = ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
)
return await self.ap.plugin_connector.require_workspace_context(execution_context)
async def _get_stored_plugin_config(
self,
request_context: RequestContext,
author: str,
plugin_name: str,
plugin: dict,
):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_plugin.PluginSetting.config)
.where(persistence_plugin.PluginSetting.workspace_uuid == request_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
)
persisted_config = result.scalar_one_or_none()
return persisted_config if persisted_config is not None else plugin['plugin_config']
async def initialize(self) -> None:
@self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> quart.Response:
@@ -170,15 +358,27 @@ class PluginsRouterGroup(group.RouterGroup):
return quart.Response(content, mimetype='application/javascript')
return quart.Response('// SDK not found', status=404, mimetype='application/javascript')
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
await self.ap.plugin_connector.require_workspace_context(request_context)
plugins = await self.ap.plugin_connector.list_plugins()
return self.success(data={'plugins': plugins})
return self.success(data={'plugins': redact_plugin_secrets(plugins)})
@self.route('/debug-info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
@self.route(
'/debug-info',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
"""Get plugin debug information including debug URL and key"""
await self.ap.plugin_connector.require_workspace_context(request_context)
debug_info = await self.ap.plugin_connector.get_debug_info()
# Get debug URL from config
@@ -196,77 +396,121 @@ class PluginsRouterGroup(group.RouterGroup):
'/<author>/<plugin_name>/upgrade',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(author: str, plugin_name: str) -> str:
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx),
self._run_fenced_plugin_operation(
execution_context,
lambda: self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx),
),
kind='plugin-operation',
name=f'plugin-upgrade-{plugin_name}',
label=f'Upgrading plugin {plugin_name}',
context=ctx,
**self._task_scope(request_context),
)
return self.success(data={'task_id': wrapper.id})
@self.route(
'/<author>/<plugin_name>',
methods=['GET', 'DELETE'],
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(author: str, plugin_name: str) -> str:
if quart.request.method == 'GET':
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None:
return self.http_status(404, -1, 'plugin not found')
return self.success(data={'plugin': plugin})
elif quart.request.method == 'DELETE':
delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.delete_plugin(
author, plugin_name, delete_data=delete_data, task_context=ctx
),
kind='plugin-operation',
name=f'plugin-remove-{plugin_name}',
label=f'Removing plugin {plugin_name}',
context=ctx,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
await self.ap.plugin_connector.require_workspace_context(request_context)
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None:
return self.http_status(404, -1, 'plugin not found')
return self.success(data={'plugin': redact_plugin_secrets(plugin)})
return self.success(data={'task_id': wrapper.id})
@self.route(
'/<author>/<plugin_name>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self._run_fenced_plugin_operation(
execution_context,
lambda: self.ap.plugin_connector.delete_plugin(
author,
plugin_name,
delete_data=delete_data,
task_context=ctx,
),
),
kind='plugin-operation',
name=f'plugin-remove-{plugin_name}',
label=f'Removing plugin {plugin_name}',
context=ctx,
**self._task_scope(request_context),
)
return self.success(data={'task_id': wrapper.id})
@self.route(
'/<author>/<plugin_name>/config',
methods=['GET', 'PUT'],
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(author: str, plugin_name: str) -> quart.Response:
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
await self.ap.plugin_connector.require_workspace_context(request_context)
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None:
return self.http_status(404, -1, 'plugin not found')
if quart.request.method == 'GET':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_plugin.PluginSetting.config)
.where(persistence_plugin.PluginSetting.plugin_author == author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
config = await self._get_stored_plugin_config(
request_context,
author,
plugin_name,
plugin,
)
return self.success(data={'config': redact_plugin_secrets(config)})
@self.route(
'/<author>/<plugin_name>/config',
methods=['PUT'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
await self.ap.plugin_connector.require_workspace_context(request_context)
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None:
return self.http_status(404, -1, 'plugin not found')
current_config = await self._get_stored_plugin_config(
request_context,
author,
plugin_name,
plugin,
)
try:
config = restore_plugin_secret_placeholders(
await quart.request.json,
current_config,
)
persisted_config = result.scalar_one_or_none()
config = persisted_config if persisted_config is not None else plugin['plugin_config']
return self.success(data={'config': config})
elif quart.request.method == 'PUT':
data = await quart.request.json
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, data)
return self.success(data={})
except ValueError as exc:
return self.http_status(400, -1, str(exc))
await self.ap.plugin_connector.require_workspace_context(request_context)
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
return self.success(data={})
@self.route(
'/<author>/<plugin_name>/readme',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(author: str, plugin_name: str) -> quart.Response:
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
await self.ap.plugin_connector.require_workspace_context(request_context)
language = quart.request.args.get('language', 'en')
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
return self.success(data={'readme': readme})
@@ -275,8 +519,10 @@ class PluginsRouterGroup(group.RouterGroup):
'/<author>/<plugin_name>/logs',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(author: str, plugin_name: str) -> quart.Response:
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
await self.ap.plugin_connector.require_workspace_context(request_context)
try:
limit = int(quart.request.args.get('limit', 200))
except (TypeError, ValueError):
@@ -291,6 +537,7 @@ class PluginsRouterGroup(group.RouterGroup):
auth_type=group.AuthType.NONE,
)
async def _(author: str, plugin_name: str) -> quart.Response:
await self._require_public_plugin_runtime_context()
icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)
icon_base64 = icon_data['plugin_icon_base64']
mime_type = icon_data['mime_type']
@@ -305,6 +552,7 @@ class PluginsRouterGroup(group.RouterGroup):
auth_type=group.AuthType.NONE,
)
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
await self._require_public_plugin_runtime_context()
asset_path = _normalize_plugin_asset_path(filepath)
if asset_path is None:
return quart.Response('Asset not found', status=404)
@@ -334,9 +582,11 @@ class PluginsRouterGroup(group.RouterGroup):
'/<author>/<plugin_name>/page-api',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(author: str, plugin_name: str) -> str:
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
"""Forward a page API request to the plugin."""
await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
if not isinstance(data, dict):
return self.http_status(400, -1, 'invalid request body')
@@ -357,9 +607,15 @@ class PluginsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, result['error'])
return self.success(data=result.get('data'))
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
@self.route(
'/github/releases',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
"""Get releases from a GitHub repository URL"""
await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
repo_url = data.get('repo_url', '')
@@ -427,16 +683,18 @@ class PluginsRouterGroup(group.RouterGroup):
'source_subdir': requested_subdir,
}
)
except httpx.RequestError as e:
return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
except httpx.RequestError:
raise
@self.route(
'/github/release-assets',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _() -> str:
async def _(request_context: RequestContext) -> str:
"""Get assets from a specific GitHub release"""
await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
owner = data.get('owner', '')
repo = data.get('repo', '')
@@ -484,13 +742,18 @@ class PluginsRouterGroup(group.RouterGroup):
# )
return self.success(data={'assets': formatted_assets})
except httpx.RequestError as e:
return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}')
except httpx.RequestError:
raise
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
@self.route(
'/install/github',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
"""Install plugin from GitHub release asset"""
limit_error = await self._check_extensions_limit()
limit_error = await self._check_extensions_limit(request_context)
if limit_error is not None:
return limit_error
@@ -503,6 +766,8 @@ class PluginsRouterGroup(group.RouterGroup):
if not asset_url:
return self.http_status(400, -1, 'Missing asset_url parameter')
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
ctx.metadata['install_source'] = 'github'
@@ -515,11 +780,19 @@ class PluginsRouterGroup(group.RouterGroup):
}
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx),
self._run_fenced_plugin_operation(
execution_context,
lambda: self.ap.plugin_connector.install_plugin(
PluginInstallSource.GITHUB,
install_info,
task_context=ctx,
),
),
kind='plugin-operation',
name='plugin-install-github',
label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}',
context=ctx,
**self._task_scope(request_context),
)
return self.success(data={'task_id': wrapper.id})
@@ -528,9 +801,10 @@ class PluginsRouterGroup(group.RouterGroup):
'/install/marketplace',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _() -> str:
limit_error = await self._check_extensions_limit()
async def _(request_context: RequestContext) -> str:
limit_error = await self._check_extensions_limit(request_context)
if limit_error is not None:
return limit_error
@@ -538,23 +812,37 @@ class PluginsRouterGroup(group.RouterGroup):
plugin_author = data.get('plugin_author', '')
plugin_name = data.get('plugin_name', '')
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
ctx.metadata['install_source'] = 'marketplace'
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
self._run_fenced_plugin_operation(
execution_context,
lambda: self.ap.plugin_connector.install_plugin(
PluginInstallSource.MARKETPLACE,
data,
task_context=ctx,
),
),
kind='plugin-operation',
name='plugin-install-marketplace',
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
context=ctx,
**self._task_scope(request_context),
)
return self.success(data={'task_id': wrapper.id})
@self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
limit_error = await self._check_extensions_limit()
@self.route(
'/install/local',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
limit_error = await self._check_extensions_limit(request_context)
if limit_error is not None:
return limit_error
@@ -563,6 +851,7 @@ class PluginsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'file is required')
file_bytes = file.read()
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
data = {
'plugin_file': file_bytes,
@@ -572,17 +861,31 @@ class PluginsRouterGroup(group.RouterGroup):
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
ctx.metadata['install_source'] = 'local'
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
self._run_fenced_plugin_operation(
execution_context,
lambda: self.ap.plugin_connector.install_plugin(
PluginInstallSource.LOCAL,
data,
task_context=ctx,
),
),
kind='plugin-operation',
name='plugin-install-local',
label=f'Installing plugin from local {file.filename}',
context=ctx,
**self._task_scope(request_context),
)
return self.success(data={'task_id': wrapper.id})
@self.route('/install/local/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
@self.route(
'/install/local/preview',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
await self.ap.plugin_connector.require_workspace_context(request_context)
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
@@ -634,12 +937,18 @@ class PluginsRouterGroup(group.RouterGroup):
)
except zipfile.BadZipFile:
return self.http_status(400, -1, 'invalid .lbpkg file')
except Exception as exc:
return self.http_status(500, -1, f'Failed to preview plugin package: {exc}')
except Exception:
raise
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
@self.route(
'/config-files',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
"""Upload a file for plugin configuration"""
await self.ap.plugin_connector.require_workspace_context(request_context)
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
@@ -650,25 +959,37 @@ class PluginsRouterGroup(group.RouterGroup):
if len(file_bytes) > MAX_FILE_SIZE:
return self.http_status(400, -1, 'file size exceeds 10MB limit')
# Generate unique file key with original extension
original_filename = file.filename
original_filename = file.filename or 'config.bin'
_, ext = os.path.splitext(original_filename)
file_key = f'plugin_config_{uuid.uuid4().hex}{ext}'
# Save file using storage manager
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
logical_key = f'plugin_config_{uuid.uuid4().hex}{ext}'
file_key = await self.ap.storage_mgr.save_scoped(
request_context,
owner_type='plugin_config',
owner=request_context.workspace_uuid,
key=logical_key,
value=file_bytes,
)
return self.success(data={'file_key': file_key})
@self.route('/config-files/<file_key>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
async def _(file_key: str) -> str:
@self.route(
'/config-files/<path:file_key>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(file_key: str, request_context: RequestContext) -> str:
"""Delete a plugin configuration file"""
# Only allow deletion of files with plugin_config_ prefix for security
if not file_key.startswith('plugin_config_'):
await self.ap.plugin_connector.require_workspace_context(request_context)
if not self.ap.storage_mgr.is_scoped_object_key(file_key, expected_owner_type='plugin_config'):
return self.http_status(400, -1, 'invalid file key')
try:
await self.ap.storage_mgr.storage_provider.delete(file_key)
await self.ap.storage_mgr.delete_scoped_object_key(
request_context,
file_key,
expected_owner_type='plugin_config',
)
return self.success(data={'deleted': True})
except Exception as e:
return self.http_status(500, -1, f'failed to delete file: {str(e)}')
except Exception:
raise
@@ -1,147 +1,292 @@
import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ... import group
@group.group_class('models/llm', '/api/v1/provider/models/llm')
class LLMModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
provider_uuid = quart.request.args.get('provider_uuid')
if provider_uuid:
return self.success(
data={'models': await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)}
)
return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})
elif quart.request.method == 'POST':
json_data = await quart.request.json
model_uuid = await self.ap.llm_model_service.create_llm_model(json_data)
return self.success(data={'uuid': model_uuid})
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
if provider_uuid:
models = await self.ap.llm_model_service.get_llm_models_by_provider(
request_context,
provider_uuid,
include_secret=include_secret,
)
else:
models = await self.ap.llm_model_service.get_llm_models(
request_context,
include_secret=include_secret,
)
return self.success(data={'models': models})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
if quart.request.method == 'GET':
model = await self.ap.llm_model_service.get_llm_model(model_uuid)
@self.route(
'',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(request_context: RequestContext) -> str:
try:
model_uuid = await self.ap.llm_model_service.create_llm_model(
request_context,
await quart.request.json,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'uuid': model_uuid})
if model is None:
return self.http_status(404, -1, 'model not found')
@self.route(
'/<model_uuid>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
model = await self.ap.llm_model_service.get_llm_model(
request_context,
model_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if model is None:
return self.http_status(404, -1, 'model not found')
return self.success(data={'model': model})
return self.success(data={'model': model})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
@self.route(
'/<model_uuid>',
methods=['PUT'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
try:
await self.ap.llm_model_service.update_llm_model(
request_context,
model_uuid,
await quart.request.json,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success()
await self.ap.llm_model_service.update_llm_model(model_uuid, json_data)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.llm_model_service.delete_llm_model(model_uuid)
return self.success()
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
json_data = await quart.request.json
await self.ap.llm_model_service.test_llm_model(model_uuid, json_data)
@self.route(
'/<model_uuid>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
await self.ap.llm_model_service.delete_llm_model(request_context, model_uuid)
return self.success()
@self.route(
'/<model_uuid>/test',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
await self.ap.llm_model_service.test_llm_model(request_context, model_uuid, await quart.request.json)
return self.success()
@group.group_class('models/embedding', '/api/v1/provider/models/embedding')
class EmbeddingModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
provider_uuid = quart.request.args.get('provider_uuid')
if provider_uuid:
return self.success(
data={
'models': await self.ap.embedding_models_service.get_embedding_models_by_provider(
provider_uuid
)
}
)
return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})
elif quart.request.method == 'POST':
json_data = await quart.request.json
model_uuid = await self.ap.embedding_models_service.create_embedding_model(json_data)
return self.success(data={'uuid': model_uuid})
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
if provider_uuid:
models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
request_context,
provider_uuid,
include_secret=include_secret,
)
else:
models = await self.ap.embedding_models_service.get_embedding_models(
request_context,
include_secret=include_secret,
)
return self.success(data={'models': models})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
if quart.request.method == 'GET':
model = await self.ap.embedding_models_service.get_embedding_model(model_uuid)
@self.route(
'',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(request_context: RequestContext) -> str:
try:
model_uuid = await self.ap.embedding_models_service.create_embedding_model(
request_context,
await quart.request.json,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'uuid': model_uuid})
if model is None:
return self.http_status(404, -1, 'model not found')
@self.route(
'/<model_uuid>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
model = await self.ap.embedding_models_service.get_embedding_model(
request_context,
model_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if model is None:
return self.http_status(404, -1, 'model not found')
return self.success(data={'model': model})
return self.success(data={'model': model})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
@self.route(
'/<model_uuid>',
methods=['PUT'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
try:
await self.ap.embedding_models_service.update_embedding_model(
request_context,
model_uuid,
await quart.request.json,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success()
await self.ap.embedding_models_service.update_embedding_model(model_uuid, json_data)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.embedding_models_service.delete_embedding_model(model_uuid)
return self.success()
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
json_data = await quart.request.json
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
@self.route(
'/<model_uuid>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
await self.ap.embedding_models_service.delete_embedding_model(request_context, model_uuid)
return self.success()
@self.route(
'/<model_uuid>/test',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
await self.ap.embedding_models_service.test_embedding_model(
request_context, model_uuid, await quart.request.json
)
return self.success()
@group.group_class('models/rerank', '/api/v1/provider/models/rerank')
class RerankModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
provider_uuid = quart.request.args.get('provider_uuid')
if provider_uuid:
return self.success(
data={
'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid)
}
)
return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()})
elif quart.request.method == 'POST':
json_data = await quart.request.json
model_uuid = await self.ap.rerank_models_service.create_rerank_model(json_data)
return self.success(data={'uuid': model_uuid})
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
if provider_uuid:
models = await self.ap.rerank_models_service.get_rerank_models_by_provider(
request_context,
provider_uuid,
include_secret=include_secret,
)
else:
models = await self.ap.rerank_models_service.get_rerank_models(
request_context,
include_secret=include_secret,
)
return self.success(data={'models': models})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
if quart.request.method == 'GET':
model = await self.ap.rerank_models_service.get_rerank_model(model_uuid)
@self.route(
'',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(request_context: RequestContext) -> str:
try:
model_uuid = await self.ap.rerank_models_service.create_rerank_model(
request_context,
await quart.request.json,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'uuid': model_uuid})
if model is None:
return self.http_status(404, -1, 'model not found')
return self.success(data={'model': model})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.rerank_models_service.delete_rerank_model(model_uuid)
return self.success()
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
json_data = await quart.request.json
await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data)
@self.route(
'/<model_uuid>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
model = await self.ap.rerank_models_service.get_rerank_model(
request_context,
model_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if model is None:
return self.http_status(404, -1, 'model not found')
return self.success(data={'model': model})
@self.route(
'/<model_uuid>',
methods=['PUT'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
try:
await self.ap.rerank_models_service.update_rerank_model(
request_context,
model_uuid,
await quart.request.json,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success()
@self.route(
'/<model_uuid>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
await self.ap.rerank_models_service.delete_rerank_model(request_context, model_uuid)
return self.success()
@self.route(
'/<model_uuid>/test',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
await self.ap.rerank_models_service.test_rerank_model(request_context, model_uuid, await quart.request.json)
return self.success()
@@ -1,56 +1,102 @@
import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ... import group
@group.group_class('models/providers', '/api/v1/provider/providers')
class ModelProvidersRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
providers = await self.ap.provider_service.get_providers()
# Add model counts
for provider in providers:
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
provider['rerank_count'] = counts['rerank_count']
return self.success(data={'providers': providers})
elif quart.request.method == 'POST':
json_data = await quart.request.json
provider_uuid = await self.ap.provider_service.create_provider(json_data)
return self.success(data={'uuid': provider_uuid})
@self.route(
'/<provider_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(provider_uuid: str) -> str:
if quart.request.method == 'GET':
provider = await self.ap.provider_service.get_provider(provider_uuid)
if provider is None:
return self.http_status(404, -1, 'provider not found')
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
async def _(request_context: RequestContext) -> str:
providers = await self.ap.provider_service.get_providers(
request_context,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
for provider in providers:
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid'])
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
provider['rerank_count'] = counts['rerank_count']
return self.success(data={'provider': provider})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.provider_service.update_provider(provider_uuid, json_data)
return self.success()
elif quart.request.method == 'DELETE':
try:
await self.ap.provider_service.delete_provider(provider_uuid)
return self.success()
except ValueError as e:
return self.http_status(400, -1, str(e))
return self.success(data={'providers': providers})
@self.route('/<provider_uuid>/scan-models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(provider_uuid: str) -> str:
@self.route(
'',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(request_context: RequestContext) -> str:
json_data = await quart.request.json
try:
provider_uuid = await self.ap.provider_service.create_provider(request_context, json_data)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'uuid': provider_uuid})
@self.route(
'/<provider_uuid>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
provider = await self.ap.provider_service.get_provider(
request_context,
provider_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if provider is None:
return self.http_status(404, -1, 'provider not found')
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider_uuid)
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
provider['rerank_count'] = counts['rerank_count']
return self.success(data={'provider': provider})
@self.route(
'/<provider_uuid>',
methods=['PUT'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
try:
await self.ap.provider_service.update_provider(request_context, provider_uuid, json_data)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success()
@self.route(
'/<provider_uuid>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
try:
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
return self.success()
except ValueError as e:
return self.http_status(400, -1, str(e))
@self.route(
'/<provider_uuid>/scan-models',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
try:
model_type = quart.request.args.get('type')
result = await self.ap.provider_service.scan_provider_models(provider_uuid, model_type)
result = await self.ap.provider_service.scan_provider_models(request_context, provider_uuid, model_type)
return self.success(data=result)
except ValueError as e:
return self.http_status(400, -1, str(e))
@@ -1,103 +1,130 @@
from __future__ import annotations
import quart
import traceback
from urllib.parse import unquote
from ....authz import Permission
from ....context import RequestContext
from ... import group
@group.group_class('mcp', '/api/v1/mcp')
class MCPRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取MCP服务器列表"""
if quart.request.method == 'GET':
servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
return self.success(data={'servers': servers})
elif quart.request.method == 'POST':
data = await quart.request.json
try:
uuid = await self.ap.mcp_service.create_mcp_server(data)
return self.success(data={'uuid': uuid})
except Exception as e:
traceback.print_exc()
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
@self.route(
'/servers',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
servers = await self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True)
return self.success(data={'servers': servers})
@self.route(
'/servers/<path:server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN
'/servers',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(server_name: str) -> str:
"""获取、更新或删除MCP服务器配置"""
server_name = unquote(server_name)
async def _(request_context: RequestContext) -> str:
data = await quart.request.json
try:
server_uuid = await self.ap.mcp_service.create_mcp_server(request_context, data)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'uuid': server_uuid})
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
@self.route(
'/servers/<path:server_name>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(server_name: str, request_context: RequestContext) -> str:
server_name = unquote(server_name)
server_data = await self.ap.mcp_service.get_mcp_server_by_name(request_context, server_name)
if server_data is None:
return self.http_status(404, -1, 'Server not found')
return self.success(data={'server': server_data})
if quart.request.method == 'GET':
return self.success(data={'server': server_data})
elif quart.request.method == 'PUT':
@self.route(
'/servers/<path:server_name>',
methods=['PUT', 'DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(server_name: str, request_context: RequestContext) -> str:
server_name = unquote(server_name)
server_data = await self.ap.mcp_service.get_mcp_server_by_name(request_context, server_name)
if server_data is None:
return self.http_status(404, -1, 'Server not found')
if quart.request.method == 'PUT':
data = await quart.request.json
try:
await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data)
return self.success()
except Exception as e:
return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}')
await self.ap.mcp_service.update_mcp_server(request_context, server_data['uuid'], data)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
else:
await self.ap.mcp_service.delete_mcp_server(request_context, server_data['uuid'])
return self.success()
elif quart.request.method == 'DELETE':
try:
await self.ap.mcp_service.delete_mcp_server(server_data['uuid'])
return self.success()
except Exception as e:
return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
@self.route('/servers/<path:server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
@self.route(
'/servers/<path:server_name>/test',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(server_name: str, request_context: RequestContext) -> str:
"""测试MCP服务器连接"""
server_name = unquote(server_name)
server_data = await quart.request.json
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
task_id = await self.ap.mcp_service.test_mcp_server(
request_context,
server_name=server_name,
server_data=server_data,
)
return self.success(data={'task_id': task_id})
@self.route('/servers/<path:server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
@self.route(
'/servers/<path:server_name>/resources',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(server_name: str, request_context: RequestContext) -> str:
"""Get resources from an MCP server"""
server_name = unquote(server_name)
try:
resources = await self.ap.mcp_service.get_mcp_server_resources(server_name)
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
runtime_info = await self.ap.mcp_service.get_runtime_info(server_name)
return self.success(
data={
'resources': resources,
'resource_templates': templates,
'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}),
}
)
except Exception as e:
return self.http_status(500, -1, f'Failed to get resources: {str(e)}')
resources = await self.ap.mcp_service.get_mcp_server_resources(request_context, server_name)
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(request_context, server_name)
runtime_info = await self.ap.mcp_service.get_runtime_info(request_context, server_name)
return self.success(
data={
'resources': resources,
'resource_templates': templates,
'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}),
}
)
@self.route(
'/servers/<path:server_name>/resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN
'/servers/<path:server_name>/resource-templates',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(server_name: str) -> str:
async def _(server_name: str, request_context: RequestContext) -> str:
"""Get resource templates from an MCP server"""
server_name = unquote(server_name)
try:
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
return self.success(data={'resource_templates': templates})
except Exception as e:
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(request_context, server_name)
return self.success(data={'resource_templates': templates})
@self.route('/servers/<path:server_name>/logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
@self.route(
'/servers/<path:server_name>/logs',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(server_name: str, request_context: RequestContext) -> str:
"""Get logs from an MCP server"""
server_name = unquote(server_name)
try:
@@ -106,24 +133,32 @@ class MCPRouterGroup(group.RouterGroup):
limit = 200
limit = min(limit, 500)
level = quart.request.args.get('level') or None
logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level)
logs = await self.ap.mcp_service.get_mcp_server_logs(
request_context,
server_name,
limit=limit,
level=level,
)
return self.success(data={'logs': logs})
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
@self.route(
'/servers/<path:server_name>/resources/read',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(server_name: str, request_context: RequestContext) -> str:
"""Read a resource from an MCP server"""
server_name = unquote(server_name)
data = await quart.request.json
uri = data.get('uri')
if not uri:
return self.http_status(400, -1, 'URI is required')
try:
envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope(
server_name,
uri,
max_bytes=data.get('max_bytes'),
include_blob=bool(data.get('include_blob', False)),
)
return self.success(data=envelope)
except Exception as e:
return self.http_status(500, -1, f'Failed to read resource: {str(e)}')
envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope(
request_context,
server_name,
uri,
max_bytes=data.get('max_bytes'),
include_blob=bool(data.get('include_blob', False)),
)
return self.success(data=envelope)
@@ -2,21 +2,28 @@ from __future__ import annotations
import quart
from ....authz import Permission
from ....context import RequestContext
from ... import group
@group.group_class('tools', '/api/v1/tools')
class ToolsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
"""获取所有可用工具列表"""
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
bound_plugins: list[str] | None = None
bound_mcp_servers: list[str] | None = None
if pipeline_uuid:
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
pipeline = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
if pipeline is None:
return self.http_status(404, -1, 'pipeline not found')
@@ -35,6 +42,7 @@ class ToolsRouterGroup(group.RouterGroup):
return self.success(
data={
'tools': await self.ap.tool_mgr.get_tool_catalog(
request_context,
bound_plugins,
bound_mcp_servers,
include_skill_authoring=True,
@@ -42,10 +50,15 @@ class ToolsRouterGroup(group.RouterGroup):
}
)
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(tool_name: str) -> str:
@self.route(
'/<tool_name>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(tool_name: str, request_context: RequestContext) -> str:
"""获取特定工具详情"""
tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True)
tools = await self.ap.tool_mgr.get_all_tools(request_context, include_skill_authoring=True)
for tool in tools:
if tool.name == tool_name:
@@ -4,6 +4,8 @@ import quart
from langbot_plugin.box.errors import BoxError
from ...authz import Permission
from ...context import RequestContext
from .. import group
@@ -12,58 +14,86 @@ class SkillsRouterGroup(group.RouterGroup):
"""Skills management API endpoints."""
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def list_or_create_skills() -> quart.Response:
if quart.request.method == 'GET':
try:
skills = await self.ap.skill_service.list_skills()
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'skills': skills})
@self.route(
'',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def list_skills(request_context: RequestContext) -> quart.Response:
try:
skills = await self.ap.skill_service.list_skills(request_context)
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'skills': skills})
@self.route(
'',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def create_skill(request_context: RequestContext) -> quart.Response:
data = await quart.request.json
if 'name' not in data or not data['name']:
return self.http_status(400, -1, 'Missing required field: name')
try:
skill = await self.ap.skill_service.create_skill(data)
skill = await self.ap.skill_service.create_skill(request_context, data)
return self.success(data={'skill': skill})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
@self.route('/<skill_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def get_update_delete_skill(skill_name: str) -> quart.Response:
if quart.request.method == 'GET':
try:
skill = await self.ap.skill_service.get_skill(skill_name)
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
if not skill:
return self.http_status(404, -1, 'Skill not found')
return self.success(data={'skill': skill})
@self.route(
'/<skill_name>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
try:
skill = await self.ap.skill_service.get_skill(request_context, skill_name)
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
if not skill:
return self.http_status(404, -1, 'Skill not found')
return self.success(data={'skill': skill})
@self.route(
'/<skill_name>',
methods=['PUT', 'DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def update_delete_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
if quart.request.method == 'PUT':
data = await quart.request.json
try:
skill = await self.ap.skill_service.update_skill(skill_name, data)
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
return self.success(data={'skill': skill})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
try:
await self.ap.skill_service.delete_skill(skill_name)
await self.ap.skill_service.delete_skill(request_context, skill_name)
return self.success()
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
@self.route('/<skill_name>/files', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def list_skill_files(skill_name: str) -> quart.Response:
@self.route(
'/<skill_name>/files',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def list_skill_files(skill_name: str, request_context: RequestContext) -> quart.Response:
"""List files in skill package directory."""
path = quart.request.args.get('path', '.').strip()
include_hidden = quart.request.args.get('include_hidden', 'false').lower() == 'true'
try:
result = await self.ap.skill_service.list_skill_files(
request_context,
skill_name,
path=path,
include_hidden=include_hidden,
@@ -73,38 +103,55 @@ class SkillsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, str(exc))
@self.route(
'/<skill_name>/files/<path:path>', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
'/<skill_name>/files/<path:path>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def read_or_write_skill_file(skill_name: str, path: str) -> quart.Response:
"""Read or write a file in skill package."""
if quart.request.method == 'GET':
try:
result = await self.ap.skill_service.read_skill_file(skill_name, path)
return self.success(data=result)
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
async def read_skill_file(skill_name: str, path: str, request_context: RequestContext) -> quart.Response:
try:
result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path)
return self.success(data=result)
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
# PUT - write file
@self.route(
'/<skill_name>/files/<path:path>',
methods=['PUT'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def write_skill_file(skill_name: str, path: str, request_context: RequestContext) -> quart.Response:
data = await quart.request.json
content = data.get('content', '')
if content is None:
return self.http_status(400, -1, 'Missing required field: content')
try:
result = await self.ap.skill_service.write_skill_file(skill_name, path, content)
result = await self.ap.skill_service.write_skill_file(request_context, skill_name, path, content)
return self.success(data=result)
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
@self.route('/<skill_name>/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def preview_skill(skill_name: str) -> quart.Response:
skill = self.ap.skill_mgr.get_skill_by_name(skill_name)
@self.route(
'/<skill_name>/preview',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def preview_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
skill = await self.ap.skill_service.get_skill(request_context, skill_name)
if not skill:
return self.http_status(404, -1, 'Skill not found')
return self.success(data={'instructions': skill.get('instructions', '')})
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def install_skill_from_github() -> quart.Response:
@self.route(
'/install/github',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def install_skill_from_github(request_context: RequestContext) -> quart.Response:
data = await quart.request.json
required_fields = ['asset_url', 'owner', 'repo']
for field in required_fields:
@@ -115,15 +162,20 @@ class SkillsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Missing required field: release_tag')
try:
skill = await self.ap.skill_service.install_from_github(data)
skill = await self.ap.skill_service.install_from_github(request_context, data)
return self.success(data={'skills': skill})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
except Exception as exc:
return self.http_status(500, -1, f'Failed to install skill: {exc}')
except Exception:
raise
@self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def preview_skill_from_github() -> quart.Response:
@self.route(
'/install/github/preview',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def preview_skill_from_github(request_context: RequestContext) -> quart.Response:
data = await quart.request.json
required_fields = ['asset_url', 'owner', 'repo']
for field in required_fields:
@@ -134,15 +186,20 @@ class SkillsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Missing required field: release_tag')
try:
preview = await self.ap.skill_service.preview_install_from_github(data)
preview = await self.ap.skill_service.preview_install_from_github(request_context, data)
return self.success(data={'skills': preview})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
except Exception as exc:
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
except Exception:
raise
@self.route('/install/upload', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def install_skill_from_upload() -> quart.Response:
@self.route(
'/install/upload',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def install_skill_from_upload(request_context: RequestContext) -> quart.Response:
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
@@ -150,6 +207,7 @@ class SkillsRouterGroup(group.RouterGroup):
try:
skill = await self.ap.skill_service.install_from_zip_upload(
request_context,
file_bytes=file.read(),
filename=file.filename or '',
source_paths=form.getlist('source_paths'),
@@ -157,34 +215,45 @@ class SkillsRouterGroup(group.RouterGroup):
return self.success(data={'skills': skill})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
except Exception as exc:
return self.http_status(500, -1, f'Failed to install skill: {exc}')
except Exception:
raise
@self.route('/install/upload/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def preview_skill_from_upload() -> quart.Response:
@self.route(
'/install/upload/preview',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def preview_skill_from_upload(request_context: RequestContext) -> quart.Response:
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
try:
preview = await self.ap.skill_service.preview_install_from_zip_upload(
request_context,
file_bytes=file.read(),
filename=file.filename or '',
)
return self.success(data={'skills': preview})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
except Exception as exc:
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
except Exception:
raise
@self.route('/scan', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def scan_skill_directory() -> quart.Response:
@self.route(
'/scan',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def scan_skill_directory(request_context: RequestContext) -> quart.Response:
path = quart.request.args.get('path', '').strip()
if not path:
return self.http_status(400, -1, 'Missing required parameter: path')
try:
result = await self.ap.skill_service.scan_directory_async(path)
result = await self.ap.skill_service.scan_directory_async(request_context, path)
return self.success(data=result)
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
@@ -1,19 +1,39 @@
from .. import group
from ...authz import Permission
from ...context import ExecutionContext, RequestContext
def collect_basic_stats(ap, request_context: RequestContext) -> dict[str, int]:
"""Collect runtime counters only from the selected Workspace placement."""
execution_context = ExecutionContext.from_request(request_context)
sessions = [
session
for session in ap.sess_mgr.session_list
if (
getattr(session, 'instance_uuid', None) == execution_context.instance_uuid
and getattr(session, 'workspace_uuid', None) == execution_context.workspace_uuid
and getattr(session, 'placement_generation', None) == execution_context.placement_generation
)
]
conversation_count = sum(
len(session.conversations if session.conversations is not None else []) for session in sessions
)
return {
'active_session_count': len(sessions),
'conversation_count': conversation_count,
'query_count': ap.query_pool.get_query_count(execution_context),
}
@group.group_class('stats', '/api/v1/stats')
class StatsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/basic', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
conv_count = 0
for session in self.ap.sess_mgr.session_list:
conv_count += len(session.conversations if session.conversations is not None else [])
return self.success(
data={
'active_session_count': len(self.ap.sess_mgr.session_list),
'conversation_count': conv_count,
'query_count': self.ap.query_pool.query_id_counter,
}
)
@self.route(
'/basic',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
return self.success(data=collect_basic_stats(self.ap, request_context))
@@ -5,7 +5,9 @@ import sqlalchemy
from .. import group
from .....utils import constants
from .....entity.persistence.metadata import Metadata
from .....entity.persistence.metadata import WorkspaceMetadata
from ...authz import Permission
from ...context import RequestContext
@group.group_class('system', '/api/v1/system')
@@ -17,17 +19,25 @@ class SystemRouterGroup(group.RouterGroup):
wizard_status = 'none'
wizard_progress = None
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress']))
)
for row in result:
if row.key == 'wizard_status':
wizard_status = row.value
elif row.key == 'wizard_progress':
try:
wizard_progress = json.loads(row.value)
except (json.JSONDecodeError, TypeError):
wizard_progress = None
authorization = quart.request.headers.get('Authorization', '')
if authorization.startswith('Bearer '):
account, _ = await self._authenticate_account(authorization.removeprefix('Bearer '))
request_context = await self._resolve_account_context(account, group.AuthType.USER_TOKEN)
if request_context is not None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(WorkspaceMetadata).where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key.in_(['wizard_status', 'wizard_progress']),
)
)
for row in result:
if row.key == 'wizard_status':
wizard_status = row.value
elif row.key == 'wizard_progress':
try:
wizard_progress = json.loads(row.value)
except (json.JSONDecodeError, TypeError):
wizard_progress = None
except Exception:
pass
@@ -67,8 +77,13 @@ class SystemRouterGroup(group.RouterGroup):
}
)
@self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
@self.route(
'/wizard/completed',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.WORKSPACE_UPDATE,
)
async def _(request_context: RequestContext) -> str:
"""Mark wizard status in metadata table and clear progress.
Accepts JSON body: { "status": "skipped" | "completed" }
@@ -80,28 +95,48 @@ class SystemRouterGroup(group.RouterGroup):
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status')
sqlalchemy.select(WorkspaceMetadata).where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key == 'wizard_status',
)
)
if result.first():
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status)
sqlalchemy.update(WorkspaceMetadata)
.where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key == 'wizard_status',
)
.values(value=status)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(Metadata).values(key='wizard_status', value=status)
sqlalchemy.insert(WorkspaceMetadata).values(
workspace_uuid=request_context.workspace_uuid,
key='wizard_status',
value=status,
)
)
# Clear wizard progress when wizard is completed/skipped
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress')
sqlalchemy.delete(WorkspaceMetadata).where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key == 'wizard_progress',
)
)
except Exception as e:
return self.http_status(500, 500, f'Failed to update wizard status: {e}')
except Exception:
raise
return self.success(data={})
@self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
@self.route(
'/wizard/progress',
methods=['PUT'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.WORKSPACE_UPDATE,
)
async def _(request_context: RequestContext) -> str:
"""Save wizard progress to metadata table.
Accepts JSON body with wizard state fields:
@@ -113,23 +148,40 @@ class SystemRouterGroup(group.RouterGroup):
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress')
sqlalchemy.select(WorkspaceMetadata).where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key == 'wizard_progress',
)
)
if result.first():
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json)
sqlalchemy.update(WorkspaceMetadata)
.where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key == 'wizard_progress',
)
.values(value=progress_json)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json)
sqlalchemy.insert(WorkspaceMetadata).values(
workspace_uuid=request_context.workspace_uuid,
key='wizard_progress',
value=progress_json,
)
)
except Exception as e:
return self.http_status(500, 500, f'Failed to save wizard progress: {e}')
except Exception:
raise
return self.success(data={})
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
@self.route(
'/tasks',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
task_type = quart.request.args.get('type')
task_kind = quart.request.args.get('kind')
@@ -138,30 +190,56 @@ class SystemRouterGroup(group.RouterGroup):
if task_kind == '':
task_kind = None
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
return self.success(
data=self.ap.task_mgr.get_tasks_dict(
task_type,
task_kind,
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
)
)
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(task_id: str) -> str:
task = self.ap.task_mgr.get_task_by_id(int(task_id))
@self.route(
'/tasks/<task_id>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(task_id: str, request_context: RequestContext) -> str:
task = self.ap.task_mgr.get_task_by_id(
int(task_id),
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
)
if task is None:
return self.http_status(404, 404, 'Task not found')
return self.success(data=task.to_dict())
@self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
@self.route(
'/storage-analysis',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> str:
return self.success(data=await self.ap.maintenance_service.get_storage_analysis(request_context))
@self.route(
'/debug/plugin/action',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RUNTIME_OPERATE,
)
async def _() -> str:
async def _(request_context: RequestContext) -> str:
if not constants.debug_mode:
return self.http_status(403, 403, 'Forbidden')
await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
class AnoymousAction:
@@ -174,6 +252,7 @@ class SystemRouterGroup(group.RouterGroup):
AnoymousAction(data['action']),
data['data'],
timeout=data.get('timeout', 10),
action_context=self.ap.plugin_connector.handler.require_bound_action_context().without_installation(),
)
return self.success(data=resp)
@@ -182,8 +261,10 @@ class SystemRouterGroup(group.RouterGroup):
'/status/plugin-system',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _() -> str:
async def _(request_context: RequestContext) -> str:
await self.ap.plugin_connector.require_workspace_context(request_context)
plugin_connector_error = 'ok'
is_connected = True
@@ -1,14 +1,53 @@
import quart
import argon2
import asyncio
import traceback
from urllib.parse import parse_qs, urlsplit
from .. import group
from .....entity.errors import account as account_errors
from ...context import RequestContext
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
@staticmethod
def _origin(value: str) -> tuple[str, str, int | None] | None:
parsed = urlsplit(value)
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
return None
return parsed.scheme, parsed.hostname.casefold(), parsed.port
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
parsed = urlsplit(redirect_uri)
if (
parsed.scheme not in {'http', 'https'}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
or parsed.path != '/auth/space/callback'
):
raise ValueError('Invalid redirect_uri parameter')
query = parse_qs(parsed.query, keep_blank_values=True)
if bind:
if query != {'mode': ['bind']}:
raise ValueError('Invalid Space binding redirect_uri')
elif query:
raise ValueError('Invalid Space login redirect_uri')
redirect_origin = self._origin(redirect_uri)
api_config = self.ap.instance_config.data.get('api', {})
trusted_origins = {
self._origin(str(api_config.get(config_key, '') or '').strip())
for config_key in ('webui_url', 'webhook_prefix')
}
trusted_origins.discard(None)
if redirect_origin not in trusted_origins:
raise ValueError('Untrusted redirect_uri origin')
return redirect_uri
async def initialize(self) -> None:
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
@@ -23,7 +62,12 @@ class UserRouterGroup(group.RouterGroup):
user_email = json_data['user']
password = json_data['password']
await self.ap.user_service.create_user(user_email, password)
try:
await self.ap.user_service.create_user(user_email, password)
except ControlPlaneDirectoryRequiredError as exc:
return self.http_status(409, exc.code, str(exc))
except PublicRegistrationClosedError:
return self.http_status(409, 'registration_closed', 'System already initialized')
return self.success()
@@ -40,7 +84,7 @@ class UserRouterGroup(group.RouterGroup):
return self.success(data={'token': token})
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(user_email: str) -> str:
token = await self.ap.user_service.generate_jwt_token(user_email)
@@ -101,15 +145,37 @@ class UserRouterGroup(group.RouterGroup):
async def _() -> str:
"""Get Space OAuth authorization URL for redirect"""
redirect_uri = quart.request.args.get('redirect_uri', '')
state = quart.request.args.get('state', '')
if not redirect_uri:
return self.fail(1, 'Missing redirect_uri parameter')
if 'state' in quart.request.args:
return self.fail(1, 'Caller-supplied OAuth state is not allowed')
try:
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
state = await self.ap.user_service.issue_space_oauth_state('login')
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url})
except Exception as e:
except ValueError as e:
return self.fail(1, str(e))
@self.route('/space/bind-authorize-url', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(request_context: RequestContext) -> str:
"""Issue an account-bound, one-time Space OAuth redirect."""
redirect_uri = quart.request.args.get('redirect_uri', '')
if not redirect_uri:
return self.fail(1, 'Missing redirect_uri parameter')
if not request_context.account_uuid:
return self.http_status(403, 'account_required', 'An Account is required')
try:
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=True)
state = await self.ap.user_service.issue_space_oauth_state(
'bind',
account_uuid=request_context.account_uuid,
)
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url})
except ValueError as e:
return self.fail(1, str(e))
@self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE)
@@ -117,11 +183,15 @@ class UserRouterGroup(group.RouterGroup):
"""Handle OAuth callback - exchange code for tokens and authenticate"""
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state')
if not code:
return self.fail(1, 'Missing authorization code')
if not state:
return self.fail(1, 'Missing state parameter')
try:
await self.ap.user_service.consume_space_oauth_state(state, 'login')
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
@@ -142,15 +212,15 @@ class UserRouterGroup(group.RouterGroup):
'user': user_obj.user,
}
)
except ControlPlaneDirectoryRequiredError as e:
return self.http_status(409, e.code, str(e))
except account_errors.AccountEmailMismatchError as e:
return self.fail(3, str(e))
except ValueError as e:
traceback.print_exc()
self.ap.logger.warning(f'Space OAuth callback failed: {e}')
return self.fail(1, str(e))
except Exception as e:
traceback.print_exc()
return self.fail(2, f'OAuth callback failed: {str(e)}')
except ValueError:
self.ap.logger.exception('Space OAuth callback failed')
return self.fail(1, 'Space OAuth failed')
except Exception:
raise
@self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
@@ -162,6 +232,7 @@ class UserRouterGroup(group.RouterGroup):
return self.success(
data={
'account_uuid': user_obj.uuid,
'user': user_obj.user,
'account_type': user_obj.account_type,
'has_password': bool(user_obj.password and user_obj.password.strip()),
@@ -176,19 +247,18 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Get account info for login page (account type and has_password)"""
"""Return instance login capabilities without disclosing an account."""
if not await self.ap.user_service.is_initialized():
return self.success(data={'initialized': False})
user_obj = await self.ap.user_service.get_first_user()
if user_obj is None:
return self.success(data={'initialized': False})
return self.success(
data={
'initialized': True,
'account_type': user_obj.account_type,
'has_password': bool(user_obj.password and user_obj.password.strip()),
# Login is selected per account in a multi-user instance. A public
# bootstrap endpoint must never project one user's authentication
# methods onto every other user or disclose that user's state.
'password_login_enabled': True,
'space_login_enabled': True,
}
)
@@ -233,7 +303,7 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state') # JWT token passed as state
state = json_data.get('state')
if not code:
return self.http_status(400, -1, 'Missing authorization code')
@@ -241,13 +311,10 @@ class UserRouterGroup(group.RouterGroup):
if not state:
return self.http_status(400, -1, 'Missing state parameter')
# Verify state is a valid JWT token
try:
user_email = await self.ap.user_service.verify_jwt_token(state)
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
except Exception:
return self.http_status(401, -1, 'Invalid or expired state')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
@@ -255,8 +322,8 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Only local accounts can bind to Space')
try:
updated_user = await self.ap.user_service.bind_space_account(user_email, code)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user)
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
return self.success(
data={
'token': jwt_token,
@@ -264,7 +331,8 @@ class UserRouterGroup(group.RouterGroup):
'account_type': updated_user.account_type,
}
)
except ValueError as e:
return self.http_status(400, -1, str(e))
except Exception as e:
return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}')
except ValueError:
self.ap.logger.exception('Space account binding failed')
return self.http_status(400, -1, 'Space account binding failed')
except Exception:
raise
@@ -1,49 +1,80 @@
from __future__ import annotations
import quart
from ...authz import Permission, has_permission
from ...context import RequestContext
from .. import group
@group.group_class('webhook_mgmt', '/api/v1/webhooks')
class WebhookManagementRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'])
async def _() -> str:
if quart.request.method == 'GET':
webhooks = await self.ap.webhook_service.get_webhooks()
return self.success(data={'webhooks': webhooks})
elif quart.request.method == 'POST':
json_data = await quart.request.json
name = json_data.get('name', '')
url = json_data.get('url', '')
description = json_data.get('description', '')
enabled = json_data.get('enabled', True)
@self.route('', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def _(request_context: RequestContext) -> str:
webhooks = await self.ap.webhook_service.get_webhooks(
request_context,
include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
)
return self.success(data={'webhooks': webhooks})
if not name:
return self.http_status(400, -1, 'Name is required')
if not url:
return self.http_status(400, -1, 'URL is required')
@self.route('', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
async def _(request_context: RequestContext) -> str:
json_data = await quart.request.get_json(silent=True) or {}
name = json_data.get('name', '')
url = json_data.get('url', '')
description = json_data.get('description', '')
enabled = json_data.get('enabled', True)
webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
return self.success(data={'webhook': webhook})
if not name:
return self.http_status(400, -1, 'Name is required')
if not url:
return self.http_status(400, -1, 'URL is required')
@self.route('/<int:webhook_id>', methods=['GET', 'PUT', 'DELETE'])
async def _(webhook_id: int) -> str:
if quart.request.method == 'GET':
webhook = await self.ap.webhook_service.get_webhook(webhook_id)
if webhook is None:
try:
webhook = await self.ap.webhook_service.create_webhook(
request_context,
name,
url,
description,
enabled,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'webhook': webhook})
@self.route('/<int:webhook_id>', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def _(webhook_id: int, request_context: RequestContext) -> str:
webhook = await self.ap.webhook_service.get_webhook(
request_context,
webhook_id,
include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
)
if webhook is None:
return self.http_status(404, -1, 'Webhook not found')
return self.success(data={'webhook': webhook})
@self.route(
'/<int:webhook_id>',
methods=['PUT', 'DELETE'],
permission=Permission.RESOURCE_MANAGE,
)
async def _(webhook_id: int, request_context: RequestContext) -> str:
if quart.request.method == 'PUT':
json_data = await quart.request.get_json(silent=True) or {}
updated = await self.ap.webhook_service.update_webhook(
request_context,
webhook_id,
json_data.get('name'),
json_data.get('url'),
json_data.get('description'),
json_data.get('enabled'),
)
if not updated:
return self.http_status(404, -1, 'Webhook not found')
return self.success(data={'webhook': webhook})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
name = json_data.get('name')
url = json_data.get('url')
description = json_data.get('description')
enabled = json_data.get('enabled')
await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.webhook_service.delete_webhook(webhook_id)
return self.success()
deleted = await self.ap.webhook_service.delete_webhook(request_context, webhook_id)
if not deleted:
return self.http_status(404, -1, 'Webhook not found')
return self.success()
@@ -30,7 +30,10 @@ class WebhookRouterGroup(group.RouterGroup):
适配器返回的响应
"""
try:
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
# Public ingress never accepts X-Workspace-Id. The opaque bot UUID
# is resolved against the already-bound runtime resource, which
# carries the trusted Workspace and placement generation.
runtime_bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
if not runtime_bot:
return quart.jsonify({'error': 'Bot not found'}), 404
@@ -49,6 +52,9 @@ class WebhookRouterGroup(group.RouterGroup):
return response
except Exception as e:
self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}')
return quart.jsonify({'error': str(e)}), 500
except Exception:
request_id = self.request_id()
self.ap.logger.error(
f'Webhook dispatch error request_id={request_id} bot={bot_uuid}: {traceback.format_exc()}'
)
return self.internal_error_response(request_id)
@@ -0,0 +1,305 @@
from __future__ import annotations
import typing
import quart
from ...authz import Permission, permissions_for_role
from ...context import RequestContext
from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError
from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership
from .....entity.persistence.workspace import WorkspaceSource
from .....workspace.collaboration import WorkspaceMemberView
from .....workspace.errors import WorkspaceNotFoundError
from .. import group
def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]:
return {
'uuid': workspace.uuid,
'instance_uuid': workspace.instance_uuid,
'name': workspace.name,
'slug': workspace.slug,
'type': workspace.type,
'status': workspace.status,
'source': workspace.source,
}
def _membership_payload(
membership: WorkspaceMembership,
*,
email: str,
) -> dict[str, typing.Any]:
return {
'uuid': membership.uuid,
'workspace_uuid': membership.workspace_uuid,
'account_uuid': membership.account_uuid,
'email': email,
'role': membership.role,
'status': membership.status,
'joined_at': membership.joined_at.isoformat() if membership.joined_at else None,
'created_at': membership.created_at.isoformat() if membership.created_at else None,
}
def _invitation_payload(invitation: WorkspaceInvitation) -> dict[str, typing.Any]:
"""Serialize an invitation without its bearer-secret hash."""
return {
'uuid': invitation.uuid,
'workspace_uuid': invitation.workspace_uuid,
'normalized_email': invitation.normalized_email,
'role': invitation.role,
'status': invitation.status,
'expires_at': invitation.expires_at.isoformat(),
'created_at': invitation.created_at.isoformat() if invitation.created_at else None,
}
@group.group_class('workspaces', '/api/v1/workspaces')
class WorkspacesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/bootstrap', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(user_email: str) -> typing.Any:
"""List the active Workspaces available to an authenticated Account.
This account-only endpoint intentionally runs before Workspace
selection. It never accepts a selector as authority and does not
choose a default Workspace for a multi-membership Account.
"""
account = await self.ap.user_service.get_user_by_email(user_email)
if account is None:
return self.http_status(401, 'invalid_authentication', 'Account not found')
accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(account.uuid)
return self.success(
data={
'workspaces': [
{
'workspace': _workspace_payload(access.workspace),
'membership': _membership_payload(access.membership, email=account.user),
'permissions': sorted(permissions_for_role(access.membership.role)),
'placement_generation': access.execution.placement_generation,
}
for access in accesses
]
}
)
@self.route('', methods=['GET', 'POST'], permission=Permission.WORKSPACE_VIEW)
async def _(request_context: RequestContext) -> typing.Any:
if quart.request.method == 'POST':
if self.ap.workspace_service.policy.multi_workspace_enabled:
return self.http_status(
409,
'control_plane_required',
'Cloud Workspaces are created by the SaaS control plane',
)
return self.http_status(403, 'edition_limit', 'This edition supports one Workspace per instance')
accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(
request_context.account_uuid
)
return self.success(data={'workspaces': [_workspace_payload(access.workspace) for access in accesses]})
@self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
async def _(request_context: RequestContext) -> typing.Any:
membership = quart.g.workspace_membership
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
if account is None:
return self.http_status(401, 'invalid_authentication', 'Account not found')
workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid)
return self.success(
data={
'workspace': _workspace_payload(workspace),
'membership': _membership_payload(membership, email=account.user),
'permissions': sorted(request_context.workspace.permissions),
'placement_generation': request_context.placement_generation,
}
)
@self.route('/<workspace_uuid>', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context)
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
return self.success(data={'workspace': _workspace_payload(workspace)})
@self.route('/<workspace_uuid>/members', methods=['GET'], permission=Permission.MEMBER_VIEW)
async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context)
members = await self.ap.workspace_collaboration_service.list_members(
workspace_uuid,
quart.g.workspace_membership,
)
return self.success(data={'members': [self._member_view_payload(item) for item in members]})
@self.route(
'/<workspace_uuid>/invitations',
methods=['GET', 'POST'],
permission=Permission.MEMBER_INVITE,
)
async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context)
if await self._requires_control_plane(workspace_uuid):
return self._control_plane_required()
if quart.request.method == 'GET':
invitations = await self.ap.workspace_collaboration_service.list_invitations(
workspace_uuid,
quart.g.workspace_membership,
)
return self.success(data={'invitations': [_invitation_payload(item) for item in invitations]})
data = await quart.request.get_json(silent=True) or {}
created = await self.ap.workspace_collaboration_service.create_invitation(
workspace_uuid,
quart.g.workspace_membership,
str(data.get('email', '')),
str(data.get('role', 'viewer')),
)
return self.success(
data={
'invitation': _invitation_payload(created.invitation),
'token': created.token,
}
)
@self.route(
'/<workspace_uuid>/invitations/<invitation_uuid>',
methods=['DELETE'],
permission=Permission.MEMBER_INVITE,
)
async def _(
workspace_uuid: str,
invitation_uuid: str,
request_context: RequestContext,
) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context)
if await self._requires_control_plane(workspace_uuid):
return self._control_plane_required()
invitation = await self.ap.workspace_collaboration_service.revoke_invitation(
workspace_uuid,
invitation_uuid,
quart.g.workspace_membership,
)
return self.success(data={'invitation': _invitation_payload(invitation)})
@self.route(
'/<workspace_uuid>/members/<account_uuid>',
methods=['PATCH', 'DELETE'],
permission=Permission.MEMBER_UPDATE_ROLE,
)
async def _(
workspace_uuid: str,
account_uuid: str,
request_context: RequestContext,
) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context)
if await self._requires_control_plane(workspace_uuid):
return self._control_plane_required()
if quart.request.method == 'DELETE':
if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions:
return self.http_status(403, 'permission_denied', 'Member removal permission is required')
member = await self.ap.workspace_collaboration_service.remove_member(
workspace_uuid,
account_uuid,
quart.g.workspace_membership,
)
return self.success(data={'account_uuid': member.account_uuid})
data = await quart.request.get_json(silent=True) or {}
member = await self.ap.workspace_collaboration_service.update_member_role(
workspace_uuid,
account_uuid,
str(data.get('role', '')),
quart.g.workspace_membership,
)
account = await self.ap.user_service.get_user_by_uuid(member.account_uuid)
return self.success(
data={
'member': _membership_payload(
member,
email=account.user if account is not None else '',
)
}
)
@staticmethod
def _require_current_workspace(workspace_uuid: str, request_context: RequestContext) -> None:
if workspace_uuid != request_context.workspace_uuid:
raise WorkspaceNotFoundError('Workspace not found')
async def _requires_control_plane(self, workspace_uuid: str) -> bool:
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
return workspace.source == WorkspaceSource.CLOUD_PROJECTION.value
def _control_plane_required(self) -> typing.Any:
return self.http_status(
409,
'control_plane_required',
'Cloud Workspace membership and invitations are managed by the SaaS control plane',
)
@staticmethod
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
return _membership_payload(view.membership, email=view.email)
@group.group_class('invitations', '/api/v1/invitations')
class InvitationsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/inspect', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> typing.Any:
data = await quart.request.get_json(silent=True) or {}
invitation, workspace = await self.ap.workspace_collaboration_service.inspect_invitation(
str(data.get('token', ''))
)
return self.success(
data={
'invitation': _invitation_payload(invitation),
'workspace': _workspace_payload(workspace),
}
)
@self.route('/accept', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> typing.Any:
data = await quart.request.get_json(silent=True) or {}
invitation_token = str(data.get('token', ''))
if not invitation_token:
return self.http_status(400, 'invitation_invalid', 'Invitation token is required')
authorization = quart.request.headers.get('Authorization', '')
if authorization.startswith('Bearer '):
account = await self.ap.user_service.get_authenticated_account(authorization.removeprefix('Bearer '))
if isinstance(account, str):
account = await self.ap.user_service.get_user_by_email(account)
if account is None:
return self.http_status(401, 'invalid_authentication', 'Account not found')
membership = await self.ap.workspace_collaboration_service.accept_invitation(
invitation_token,
account.uuid,
)
token = await self.ap.user_service.generate_jwt_token(account)
return self.success(data={'token': token, 'workspace_uuid': membership.workspace_uuid})
registration = data.get('registration')
if not isinstance(registration, dict):
return self.http_status(
401,
'account_exists_login_required',
'Sign in or provide registration details to accept this invitation',
)
password = registration.get('password')
if not isinstance(password, str) or len(password) < 8:
return self.http_status(400, 'invalid_password', 'Password must contain at least 8 characters')
try:
_, membership, token = await self.ap.user_service.register_invited_account(
invitation_token,
str(registration.get('email', '')),
password,
)
except ControlPlaneDirectoryRequiredError as exc:
return self.http_status(409, exc.code, str(exc))
except AccountExistsLoginRequiredError as exc:
return self.http_status(409, exc.code, str(exc))
return self.success(data={'token': token, 'workspace_uuid': membership.workspace_uuid})
+219 -64
View File
@@ -1,97 +1,252 @@
from __future__ import annotations
import dataclasses
import datetime
import hashlib
import secrets
import typing
import uuid
import sqlalchemy
from ....core import app
from ....entity.persistence import apikey
from ....workspace.errors import WorkspaceNotFoundError
from ..authz import Permission, PermissionDeniedError
from .tenant import TenantContext, require_workspace_uuid, scope_statement
if typing.TYPE_CHECKING:
from ....core.app import Application
@dataclasses.dataclass(frozen=True, slots=True)
class ApiKeyIdentity:
"""Trusted Workspace identity derived from an API-key secret."""
instance_uuid: str
workspace_uuid: str
placement_generation: int
api_key_uuid: str
permissions: frozenset[str]
class ApiKeyService:
ap: app.Application
"""Manage hashed, Workspace-bound API keys."""
def __init__(self, ap: app.Application) -> None:
def __init__(self, ap: Application) -> None:
self.ap = ap
async def get_api_keys(self) -> list[dict]:
"""Get all API keys"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey))
@staticmethod
def _hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
keys = result.all()
return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys]
@staticmethod
def _utcnow() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
async def create_api_key(self, name: str, description: str = '') -> dict:
"""Create a new API key"""
# Generate a secure random API key
key = f'lbk_{secrets.token_urlsafe(32)}'
@staticmethod
def _normalize_scopes(
scopes: typing.Iterable[str] | None,
*,
default: typing.Iterable[str] = (),
) -> list[str]:
requested = list(default if scopes is None else scopes)
valid = {permission.value for permission in Permission}
normalized: list[str] = []
for scope in requested:
if not isinstance(scope, str):
raise ValueError('API key scopes must be strings')
value = scope.strip()
if value not in valid:
raise ValueError(f'Unknown API key scope: {value}')
if value not in normalized:
normalized.append(value)
return normalized
key_data = {'name': name, 'key': key, 'description': description}
def _serialize(self, row: typing.Any) -> dict[str, typing.Any]:
value = self.ap.persistence_mgr.serialize_model(apikey.ApiKey, row)
value.pop('key_hash', None)
# The secret is deliberately unrecoverable after creation.
value['secret_available'] = False
return value
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data))
# Retrieve the created key
async def get_api_keys(self, context: TenantContext) -> list[dict[str, typing.Any]]:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
scope_statement(
sqlalchemy.select(apikey.ApiKey).order_by(apikey.ApiKey.created_at, apikey.ApiKey.id),
apikey.ApiKey,
context,
)
)
created_key = result.first()
return [self._serialize(key) for key in result.all()]
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key)
async def create_api_key(
self,
context: TenantContext,
name: str,
description: str = '',
*,
scopes: typing.Iterable[str] | None = None,
expires_at: datetime.datetime | None = None,
) -> dict[str, typing.Any]:
workspace_uuid = require_workspace_uuid(context)
normalized_name = name.strip()
if not normalized_name:
raise ValueError('Name is required')
if expires_at is not None:
if expires_at.tzinfo is not None:
expires_at = expires_at.astimezone(datetime.UTC).replace(tzinfo=None)
if expires_at <= self._utcnow():
raise ValueError('API key expiry must be in the future')
async def get_api_key(self, key_id: int) -> dict | None:
"""Get a specific API key by ID"""
default_scopes = getattr(getattr(context, 'workspace', None), 'permissions', frozenset())
normalized_scopes = self._normalize_scopes(scopes, default=default_scopes)
allowed_scopes = frozenset(default_scopes)
unauthorized_scopes = sorted(set(normalized_scopes) - allowed_scopes)
if unauthorized_scopes:
# API-key management delegates the caller's authority; it must not
# become a path for minting a stronger principal.
raise PermissionDeniedError(unauthorized_scopes[0])
secret = f'lbk_{secrets.token_urlsafe(32)}'
key_uuid = str(uuid.uuid4())
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(apikey.ApiKey).values(
uuid=key_uuid,
workspace_uuid=workspace_uuid,
created_by_account_uuid=getattr(context, 'account_uuid', None),
name=normalized_name,
key_hash=self._hash_secret(secret),
scopes=normalized_scopes,
status=apikey.ApiKeyStatus.ACTIVE.value,
expires_at=expires_at,
description=description.strip(),
)
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id)
scope_statement(
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.uuid == key_uuid),
apikey.ApiKey,
workspace_uuid,
)
)
created = result.first()
if created is None:
raise RuntimeError('Created API key could not be loaded')
value = self._serialize(created)
value['key'] = secret
value['secret_available'] = True
return value
async def get_api_key(self, context: TenantContext, key_id: int) -> dict[str, typing.Any] | None:
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id),
apikey.ApiKey,
context,
)
)
key = result.first()
return None if key is None else self._serialize(key)
if key is None:
async def authenticate_api_key(self, secret: str) -> ApiKeyIdentity | None:
"""Authenticate a secret and derive its Workspace without trusting headers."""
if not isinstance(secret, str) or not secret.strip():
return None
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key)
async def verify_api_key(self, key: str) -> bool:
"""Verify if an API key is valid.
A key is accepted if it matches the global API key configured in
``config.yaml`` (``api.global_api_key``) — which requires no login
session and no database record — or if it matches a key created via
the web UI (stored in the database, prefixed with ``lbk_``).
"""
if not isinstance(key, str) or not key:
return False
# 1. Global API key from config.yaml (no DB lookup, no login state).
# Note: config completion only backfills top-level keys, so existing
# installs may not have this key — access it defensively.
global_api_key = self.ap.instance_config.data.get('api', {}).get('global_api_key', '')
if global_api_key and secrets.compare_digest(key, global_api_key):
return True
# 2. Web-UI-created keys are stored in the database and prefixed lbk_.
if not key.startswith('lbk_'):
return False
global_secret = self.ap.instance_config.data.get('api', {}).get('global_api_key', '')
if global_secret and secrets.compare_digest(secret, global_secret):
workspace_service = getattr(self.ap, 'workspace_service', None)
if workspace_service is None or workspace_service.policy.multi_workspace_enabled:
return None
binding = await workspace_service.get_local_execution_binding()
return ApiKeyIdentity(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
api_key_uuid='global-oss-api-key',
permissions=frozenset(permission.value for permission in Permission),
)
if not secret.startswith('lbk_'):
return None
secret_hash = self._hash_secret(secret)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key_hash == secret_hash)
)
key = result.first()
if key is None or key.status != apikey.ApiKeyStatus.ACTIVE.value:
return None
now = self._utcnow()
if key.expires_at is not None and key.expires_at <= now:
return None
raw_scopes = list(key.scopes or [])
permissions = (
frozenset(permission.value for permission in Permission)
if '*' in raw_scopes
else frozenset(self._normalize_scopes(raw_scopes))
)
binding = await self.ap.workspace_service.get_execution_binding(key.workspace_uuid)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(apikey.ApiKey)
.where(
apikey.ApiKey.id == key.id,
apikey.ApiKey.workspace_uuid == key.workspace_uuid,
apikey.ApiKey.key_hash == secret_hash,
)
.values(last_used_at=now)
)
return ApiKeyIdentity(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
api_key_uuid=key.uuid,
permissions=permissions,
)
key_obj = result.first()
return key_obj is not None
async def verify_api_key(self, secret: str) -> bool:
try:
return await self.authenticate_api_key(secret) is not None
except Exception:
return False
async def delete_api_key(self, key_id: int) -> None:
"""Delete an API key"""
await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id))
async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None:
"""Update an API key's metadata (name, description)"""
update_data = {}
if name is not None:
update_data['name'] = name
if description is not None:
update_data['description'] = description
if update_data:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data)
async def delete_api_key(self, context: TenantContext, key_id: int) -> None:
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(apikey.ApiKey)
.where(apikey.ApiKey.id == key_id)
.values(status=apikey.ApiKeyStatus.REVOKED.value),
apikey.ApiKey,
context,
)
)
if getattr(result, 'rowcount', 0) == 0:
raise WorkspaceNotFoundError('API key not found')
async def update_api_key(
self,
context: TenantContext,
key_id: int,
name: str | None = None,
description: str | None = None,
) -> None:
update_data: dict[str, typing.Any] = {}
if name is not None:
normalized_name = name.strip()
if not normalized_name:
raise ValueError('Name is required')
update_data['name'] = normalized_name
if description is not None:
update_data['description'] = description.strip()
if not update_data:
return
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data),
apikey.ApiKey,
context,
)
)
if getattr(result, 'rowcount', 0) == 0:
raise WorkspaceNotFoundError('API key not found')
+110 -42
View File
@@ -2,11 +2,12 @@ from __future__ import annotations
import uuid
import sqlalchemy
import typing
from ....core import app
from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline
from ....workspace.errors import WorkspaceNotFoundError
from .tenant import TenantContext, require_workspace_uuid, scope_statement
class BotService:
@@ -17,9 +18,11 @@ class BotService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_bots(self, include_secret: bool = True) -> list[dict]:
async def get_bots(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
"""获取所有机器人"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
result = await self.ap.persistence_mgr.execute_async(
scope_statement(sqlalchemy.select(persistence_bot.Bot), persistence_bot.Bot, context)
)
bots = result.all()
@@ -29,10 +32,14 @@ class BotService:
return [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) for bot in bots]
async def get_bot(self, bot_uuid: str, include_secret: bool = True) -> dict | None:
async def get_bot(self, context: TenantContext, bot_uuid: str, include_secret: bool = False) -> dict | None:
"""获取机器人"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)
scope_statement(
sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid),
persistence_bot.Bot,
context,
)
)
bot = result.first()
@@ -46,15 +53,20 @@ class BotService:
return self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns)
async def get_runtime_bot_info(self, bot_uuid: str, include_secret: bool = True) -> dict:
async def get_runtime_bot_info(
self,
context: TenantContext,
bot_uuid: str,
include_secret: bool = False,
) -> dict:
"""获取机器人运行时信息"""
persistence_bot = await self.get_bot(bot_uuid, include_secret)
persistence_bot = await self.get_bot(context, bot_uuid, include_secret)
if persistence_bot is None:
raise Exception('Bot not found')
raise WorkspaceNotFoundError('Bot not found')
adapter_runtime_values = {}
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is not None:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
@@ -86,22 +98,29 @@ class BotService:
return persistence_bot
async def create_bot(self, bot_data: dict) -> str:
async def create_bot(self, context: TenantContext, bot_data: dict) -> str:
"""Create bot"""
workspace_uuid = require_workspace_uuid(context)
# Check limitation
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_bots = limitation.get('max_bots', -1)
if max_bots >= 0:
existing_bots = await self.get_bots()
existing_bots = await self.get_bots(context)
if len(existing_bots) >= max_bots:
raise ValueError(f'Maximum number of bots ({max_bots}) reached')
# TODO: 检查配置信息格式
bot_data = bot_data.copy()
bot_data['uuid'] = str(uuid.uuid4())
bot_data['workspace_uuid'] = workspace_uuid
# bind the most recently updated pipeline if any exist
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
scope_statement(
sqlalchemy.select(persistence_pipeline.LegacyPipeline),
persistence_pipeline.LegacyPipeline,
context,
)
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
.limit(1)
)
@@ -112,61 +131,84 @@ class BotService:
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
bot = await self.get_bot(bot_data['uuid'])
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
await self.ap.platform_mgr.load_bot(bot)
await self.ap.platform_mgr.load_bot(context, bot)
return bot_data['uuid']
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
async def update_bot(self, context: TenantContext, bot_uuid: str, bot_data: dict) -> None:
"""Update bot"""
workspace_uuid = require_workspace_uuid(context)
update_data = bot_data.copy()
if 'uuid' in update_data:
del update_data['uuid']
update_data.pop('uuid', None)
update_data.pop('workspace_uuid', None)
# set use_pipeline_name
if 'use_pipeline_uuid' in update_data:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
scope_statement(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
),
persistence_pipeline.LegacyPipeline,
workspace_uuid,
)
)
pipeline = result.first()
if pipeline is not None:
update_data['use_pipeline_name'] = pipeline.name
else:
raise Exception('Pipeline not found')
raise WorkspaceNotFoundError('Pipeline not found')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid),
persistence_bot.Bot,
workspace_uuid,
)
)
await self.ap.platform_mgr.remove_bot(bot_uuid)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Bot not found')
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
# select from db
bot = await self.get_bot(bot_uuid)
bot = await self.get_bot(context, bot_uuid, include_secret=True)
runtime_bot = await self.ap.platform_mgr.load_bot(bot)
runtime_bot = await self.ap.platform_mgr.load_bot(context, bot)
if runtime_bot.enable:
await runtime_bot.run()
# update all conversation that use this bot
for session in self.ap.sess_mgr.session_list:
if session.using_conversation is not None and session.using_conversation.bot_uuid == bot_uuid:
if (
session.using_conversation is not None
and session.using_conversation.bot_uuid == bot_uuid
and getattr(session, 'workspace_uuid', workspace_uuid) == workspace_uuid
):
session.using_conversation = None
async def delete_bot(self, bot_uuid: str) -> None:
async def delete_bot(self, context: TenantContext, bot_uuid: str) -> None:
"""Delete bot"""
await self.ap.platform_mgr.remove_bot(bot_uuid)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid),
persistence_bot.Bot,
context,
)
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Bot not found')
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
async def list_event_logs(
self, bot_uuid: str, from_index: int, max_count: int
) -> typing.Tuple[list[dict], int, int, int]:
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
self, context: TenantContext, bot_uuid: str, from_index: int, max_count: int
) -> tuple[list[dict], int]:
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
raise WorkspaceNotFoundError('Bot not found')
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is None:
raise Exception('Bot not found')
@@ -174,7 +216,14 @@ class BotService:
return [log.to_json() for log in logs], total_count
async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None:
async def send_message(
self,
context: TenantContext,
bot_uuid: str,
target_type: str,
target_id: str,
message_chain_data: dict,
) -> None:
"""Send message to a specific target via bot
Args:
@@ -183,11 +232,14 @@ class BotService:
target_id: The ID of the target
message_chain_data: The message chain data in dict format
"""
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
raise WorkspaceNotFoundError('Bot not found')
# Import here to avoid circular imports
import langbot_plugin.api.entities.builtin.platform.message as platform_message
# Get runtime bot
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is None:
raise Exception(f'Bot not found: {bot_uuid}')
@@ -202,19 +254,29 @@ class BotService:
# ============ Bot Admins ============
async def get_bot_admins(self, bot_uuid: str) -> list[dict]:
async def get_bot_admins(self, context: TenantContext, bot_uuid: str) -> list[dict]:
from ....entity.persistence import bot as persistence_bot
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
raise WorkspaceNotFoundError('Bot not found')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid)
scope_statement(
sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid),
persistence_bot.BotAdmin,
context,
)
)
return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()]
async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
async def add_bot_admin(self, context: TenantContext, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
from ....entity.persistence import bot as persistence_bot
workspace_uuid = require_workspace_uuid(context)
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
raise WorkspaceNotFoundError('Bot not found')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_bot.BotAdmin).values(
workspace_uuid=workspace_uuid,
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
@@ -222,12 +284,18 @@ class BotService:
)
return result.inserted_primary_key[0]
async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None:
async def delete_bot_admin(self, context: TenantContext, bot_uuid: str, admin_id: int) -> None:
from ....entity.persistence import bot as persistence_bot
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
raise WorkspaceNotFoundError('Bot not found')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bot.BotAdmin).where(
persistence_bot.BotAdmin.bot_uuid == bot_uuid,
persistence_bot.BotAdmin.id == admin_id,
scope_statement(
sqlalchemy.delete(persistence_bot.BotAdmin).where(
persistence_bot.BotAdmin.bot_uuid == bot_uuid,
persistence_bot.BotAdmin.id == admin_id,
),
persistence_bot.BotAdmin,
context,
)
)
+142 -44
View File
@@ -2,8 +2,13 @@ from __future__ import annotations
import sqlalchemy
from ....api.http.authz import WorkspaceRequiredError
from ....api.http.context import ExecutionContext, RequestContext
from ....core import app
from ....entity.persistence import rag as persistence_rag
from ....workspace.errors import WorkspaceNotFoundError
from .secrets import redact_secrets, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid
class KnowledgeService:
@@ -14,16 +19,41 @@ class KnowledgeService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_knowledge_bases(self) -> list[dict]:
@staticmethod
def _execution_context(context: RequestContext | ExecutionContext) -> ExecutionContext:
if isinstance(context, RequestContext):
return ExecutionContext.from_request(context)
if isinstance(context, ExecutionContext):
return context
raise WorkspaceRequiredError('RequestContext or ExecutionContext is required')
async def get_knowledge_bases(self, context: TenantContext, *, include_secret: bool = False) -> list[dict]:
"""获取所有知识库"""
return await self.ap.rag_mgr.get_all_knowledge_base_details()
require_workspace_uuid(context)
knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
return knowledge_bases if include_secret else [redact_secrets(base) for base in knowledge_bases]
async def get_knowledge_base(self, kb_uuid: str) -> dict | None:
async def get_knowledge_base(
self,
context: TenantContext,
kb_uuid: str,
*,
include_secret: bool = False,
) -> dict | None:
"""获取知识库"""
return await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid)
require_workspace_uuid(context)
knowledge_base = await self.ap.rag_mgr.get_knowledge_base_details(context, kb_uuid)
if knowledge_base is None or include_secret:
return knowledge_base
return redact_secrets(knowledge_base)
async def create_knowledge_base(self, kb_data: dict) -> str:
async def create_knowledge_base(
self,
context: RequestContext | ExecutionContext,
kb_data: dict,
) -> str:
"""创建知识库"""
require_workspace_uuid(context)
# In new architecture, we delegate entirely to RAGManager which uses plugins.
# Legacy internal KB creation is removed.
@@ -31,17 +61,19 @@ class KnowledgeService:
if not knowledge_engine_plugin_id:
raise ValueError('knowledge_engine_plugin_id is required')
creation_settings = kb_data.get('creation_settings', {})
creation_settings = restore_secret_placeholders(kb_data.get('creation_settings', {}))
retrieval_settings = kb_data.get('retrieval_settings', {})
# Validate required fields based on plugin's creation_schema and retrieval_schema
await self._validate_schema_required_fields(
context,
knowledge_engine_plugin_id,
creation_settings,
retrieval_settings,
)
kb = await self.ap.rag_mgr.create_knowledge_base(
context,
name=kb_data.get('name', 'Untitled'),
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
creation_settings=creation_settings,
@@ -52,6 +84,7 @@ class KnowledgeService:
async def _validate_schema_required_fields(
self,
context: RequestContext | ExecutionContext,
plugin_id: str,
creation_settings: dict,
retrieval_settings: dict,
@@ -69,7 +102,11 @@ class KnowledgeService:
Raises:
ValueError: If any required field is missing or empty.
"""
if not self.ap.plugin_connector.is_enable_plugin:
return
# Validate creation_schema
await self.ap.plugin_connector.require_workspace_context(context)
try:
creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
self._check_required_fields(creation_schema, creation_settings, 'creation_settings')
@@ -79,6 +116,7 @@ class KnowledgeService:
self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}')
# Validate retrieval_schema
await self.ap.plugin_connector.require_workspace_context(context)
try:
retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings')
@@ -151,8 +189,16 @@ class KnowledgeService:
)
raise ValueError(f'{field_label} is required ({context}.{field_name})')
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
async def update_knowledge_base(
self,
context: RequestContext | ExecutionContext,
kb_uuid: str,
kb_data: dict,
) -> None:
"""更新知识库"""
workspace_uuid = require_workspace_uuid(context)
if await self.get_knowledge_base(context, kb_uuid) is None:
raise WorkspaceNotFoundError('Knowledge base not found')
# Filter to only mutable fields
filtered_data = {k: v for k, v in kb_data.items() if k in persistence_rag.KnowledgeBase.MUTABLE_FIELDS}
@@ -162,17 +208,18 @@ class KnowledgeService:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_rag.KnowledgeBase)
.values(filtered_data)
.where(persistence_rag.KnowledgeBase.workspace_uuid == workspace_uuid)
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
await self.ap.rag_mgr.remove_knowledge_base_from_runtime(kb_uuid)
await self.ap.rag_mgr.remove_knowledge_base_from_runtime(context, kb_uuid)
kb = await self.get_knowledge_base(kb_uuid)
kb = await self.get_knowledge_base(context, kb_uuid, include_secret=True)
if kb is None:
raise Exception('Knowledge base not found after update')
raise WorkspaceNotFoundError('Knowledge base not found')
await self.ap.rag_mgr.load_knowledge_base(kb)
await self.ap.rag_mgr.load_knowledge_base(context, kb)
async def _check_doc_capability(self, kb_uuid: str, operation: str) -> None:
async def _check_doc_capability(self, context: TenantContext, kb_uuid: str, operation: str) -> None:
"""Check if the KB's Knowledge Engine supports document operations.
Args:
@@ -182,104 +229,145 @@ class KnowledgeService:
Raises:
Exception: If the KB does not support doc_ingestion.
"""
kb_info = await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid)
kb_info = await self.ap.rag_mgr.get_knowledge_base_details(context, kb_uuid)
if not kb_info:
raise Exception('Knowledge base not found')
raise WorkspaceNotFoundError('Knowledge base not found')
capabilities = kb_info.get('knowledge_engine', {}).get('capabilities', [])
if 'doc_ingestion' not in capabilities:
raise Exception(f'This knowledge base does not support {operation}')
async def store_file(self, kb_uuid: str, file_id: str, parser_plugin_id: str | None = None) -> str:
async def store_file(
self,
context: RequestContext | ExecutionContext,
kb_uuid: str,
file_id: str,
parser_plugin_id: str | None = None,
) -> str:
"""存储文件"""
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
execution_context = self._execution_context(context)
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
if runtime_kb is None:
raise Exception('Knowledge base not found')
raise WorkspaceNotFoundError('Knowledge base not found')
await self._check_doc_capability(kb_uuid, 'document upload')
await self._check_doc_capability(context, kb_uuid, 'document upload')
result = await runtime_kb.store_file(file_id, parser_plugin_id=parser_plugin_id)
result = await runtime_kb.store_file(execution_context, file_id, parser_plugin_id=parser_plugin_id)
# Update the KB's updated_at timestamp
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_rag.KnowledgeBase)
.values(updated_at=sqlalchemy.func.now())
.where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
return result
async def retrieve_knowledge_base(
self, kb_uuid: str, query: str, retrieval_settings: dict | None = None
self,
context: RequestContext | ExecutionContext,
kb_uuid: str,
query: str,
retrieval_settings: dict | None = None,
) -> list[dict]:
"""检索知识库"""
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
execution_context = self._execution_context(context)
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
if runtime_kb is None:
raise Exception('Knowledge base not found')
raise WorkspaceNotFoundError('Knowledge base not found')
# Pass retrieval_settings
results = await runtime_kb.retrieve(query, settings=retrieval_settings)
results = await runtime_kb.retrieve(execution_context, query, settings=retrieval_settings)
return [result.model_dump() for result in results]
async def get_files_by_knowledge_base(self, kb_uuid: str) -> list[dict]:
async def get_files_by_knowledge_base(self, context: TenantContext, kb_uuid: str) -> list[dict]:
"""获取知识库文件"""
workspace_uuid = require_workspace_uuid(context)
if await self.get_knowledge_base(context, kb_uuid) is None:
raise WorkspaceNotFoundError('Knowledge base not found')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid)
sqlalchemy.select(persistence_rag.File)
.where(persistence_rag.File.workspace_uuid == workspace_uuid)
.where(persistence_rag.File.kb_id == kb_uuid)
)
files = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_rag.File, file) for file in files]
async def delete_file(self, kb_uuid: str, file_id: str) -> None:
async def delete_file(
self,
context: RequestContext | ExecutionContext,
kb_uuid: str,
file_id: str,
) -> None:
"""删除文件"""
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
execution_context = self._execution_context(context)
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
if runtime_kb is None:
raise Exception('Knowledge base not found')
raise WorkspaceNotFoundError('Knowledge base not found')
await self._check_doc_capability(kb_uuid, 'document deletion')
await self._check_doc_capability(context, kb_uuid, 'document deletion')
await runtime_kb.delete_file(file_id)
await runtime_kb.delete_file(execution_context, file_id)
# Update the KB's updated_at timestamp
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_rag.KnowledgeBase)
.values(updated_at=sqlalchemy.func.now())
.where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
async def delete_knowledge_base(self, kb_uuid: str) -> None:
async def delete_knowledge_base(
self,
context: RequestContext | ExecutionContext,
kb_uuid: str,
) -> None:
"""删除知识库"""
# Delete from DB first to commit the deletion, then clean up runtime/plugin (best-effort)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
workspace_uuid = require_workspace_uuid(context)
if await self.get_knowledge_base(context, kb_uuid) is None:
raise WorkspaceNotFoundError('Knowledge base not found')
# delete files
# NOTE: Chunk cleanup is for legacy (pre-plugin) KBs that stored chunks locally.
# For plugin-based Knowledge Engines, the Chunk table is not populated, so this is a no-op.
files = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid)
sqlalchemy.select(persistence_rag.File)
.where(persistence_rag.File.workspace_uuid == workspace_uuid)
.where(persistence_rag.File.kb_id == kb_uuid)
)
for file in files:
# delete chunks
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_rag.Chunk).where(persistence_rag.Chunk.file_id == file.uuid)
sqlalchemy.delete(persistence_rag.Chunk)
.where(persistence_rag.Chunk.workspace_uuid == workspace_uuid)
.where(persistence_rag.Chunk.file_id == file.uuid)
)
# delete file
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file.uuid)
sqlalchemy.delete(persistence_rag.File)
.where(persistence_rag.File.workspace_uuid == workspace_uuid)
.where(persistence_rag.File.uuid == file.uuid)
)
# Remove from runtime and notify plugin (best-effort, DB is already cleaned up)
await self.ap.rag_mgr.delete_knowledge_base(kb_uuid)
# Remove from runtime and notify plugin before deleting the owning row.
await self.ap.rag_mgr.delete_knowledge_base(context, kb_uuid)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_rag.KnowledgeBase)
.where(persistence_rag.KnowledgeBase.workspace_uuid == workspace_uuid)
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
# ================= Knowledge Engine Discovery =================
async def list_knowledge_engines(self) -> list[dict]:
async def list_knowledge_engines(self, context: TenantContext) -> list[dict]:
"""List all available Knowledge Engines from plugins."""
require_workspace_uuid(context)
engines = []
if not self.ap.plugin_connector.is_enable_plugin:
return engines
await self.ap.plugin_connector.require_workspace_context(context)
# Get KnowledgeEngine plugins
try:
@@ -290,10 +378,12 @@ class KnowledgeService:
return engines
async def list_parsers(self, mime_type: str | None = None) -> list[dict]:
async def list_parsers(self, context: TenantContext, mime_type: str | None = None) -> list[dict]:
"""List available parsers, optionally filtered by MIME type."""
require_workspace_uuid(context)
if not self.ap.plugin_connector.is_enable_plugin:
return []
await self.ap.plugin_connector.require_workspace_context(context)
try:
parsers = await self.ap.plugin_connector.list_parsers()
if mime_type:
@@ -303,16 +393,24 @@ class KnowledgeService:
self.ap.logger.warning(f'Failed to list parsers: {e}')
return []
async def get_engine_creation_schema(self, plugin_id: str) -> dict:
async def get_engine_creation_schema(self, context: TenantContext, plugin_id: str) -> dict:
"""Get creation settings schema for a specific Knowledge Engine."""
require_workspace_uuid(context)
if not self.ap.plugin_connector.is_enable_plugin:
return {}
await self.ap.plugin_connector.require_workspace_context(context)
try:
return await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
except Exception as e:
self.ap.logger.warning(f'Failed to get creation schema for {plugin_id}: {e}')
return {}
async def get_engine_retrieval_schema(self, plugin_id: str) -> dict:
async def get_engine_retrieval_schema(self, context: TenantContext, plugin_id: str) -> dict:
"""Get retrieval settings schema for a specific Knowledge Engine."""
require_workspace_uuid(context)
if not self.ap.plugin_connector.is_enable_plugin:
return {}
await self.ap.plugin_connector.require_workspace_context(context)
try:
return await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
except Exception as e:
+152 -72
View File
@@ -11,11 +11,15 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import bstorage as persistence_bstorage
from ....entity.persistence import monitoring as persistence_monitoring
from ..authz import WorkspaceRequiredError
from ..context import ExecutionContext
from .tenant import TenantContext, require_workspace_uuid
LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7
DEFAULT_LOG_RETENTION_DAYS = 3
UPLOAD_OWNER_TYPES = ('upload_image', 'upload_document', 'upload')
class MaintenanceService:
@@ -26,7 +30,10 @@ class MaintenanceService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def cleanup_expired_files(self) -> dict[str, int]:
async def cleanup_expired_files(self, context: ExecutionContext) -> dict[str, int]:
if not isinstance(context, ExecutionContext):
raise WorkspaceRequiredError('Storage cleanup requires an ExecutionContext')
require_workspace_uuid(context)
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
upload_retention_days = self._positive_int(
cleanup_cfg.get('uploaded_file_retention_days'),
@@ -40,11 +47,14 @@ class MaintenanceService:
)
return {
'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days),
'log_files': self._cleanup_expired_log_files(log_retention_days),
'uploaded_files': await self._cleanup_expired_uploaded_files(context, upload_retention_days),
'log_files': self._cleanup_expired_log_files(log_retention_days)
if await self._is_oss_singleton(context)
else 0,
}
async def get_storage_analysis(self) -> dict[str, Any]:
async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]:
require_workspace_uuid(context)
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
upload_retention_days = self._positive_int(
cleanup_cfg.get('uploaded_file_retention_days'),
@@ -62,15 +72,20 @@ class MaintenanceService:
database_path = (
Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None
)
roots: list[tuple[str, Path | None]] = [
('database', database_path),
('logs', Path('data/logs')),
('storage', Path('data/storage')),
('vector_store', Path('data/chroma')),
('plugins', Path('data/plugins')),
('mcp', Path('data/mcp')),
('temp', Path('data/temp')),
]
is_oss_singleton = await self._is_oss_singleton(context)
if is_oss_singleton:
roots: list[tuple[str, Path | None]] = [
('database', database_path),
('logs', Path('data/logs')),
('storage', Path('data/storage')),
('vector_store', Path('data/chroma')),
('plugins', Path('data/plugins')),
('mcp', Path('data/mcp')),
('temp', Path('data/temp')),
]
else:
scoped_storage_path = Path('data/storage') / self.ap.storage_mgr.scoped_prefix(context)
roots = [('storage', scoped_storage_path)]
sections = []
for key, path in roots:
@@ -84,10 +99,10 @@ class MaintenanceService:
}
)
monitoring_counts = await self._monitoring_counts()
binary_storage = await self._binary_storage_stats()
upload_candidates = await self._expired_uploaded_candidates(upload_retention_days)
log_candidates = self._expired_log_candidates(log_retention_days)
monitoring_counts = await self._monitoring_counts(context)
binary_storage = await self._binary_storage_stats(context)
upload_candidates = await self._expired_uploaded_candidates(context, upload_retention_days)
log_candidates = self._expired_log_candidates(log_retention_days) if is_oss_singleton else []
return {
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
@@ -105,14 +120,32 @@ class MaintenanceService:
'uploaded_files': upload_candidates,
'log_files': log_candidates,
},
'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {},
'tasks': self.ap.task_mgr.get_stats() if is_oss_singleton and self.ap.task_mgr else {},
}
async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int:
async def _is_oss_singleton(self, context: TenantContext) -> bool:
try:
await self.ap.workspace_service.get_local_execution_binding(
require_workspace_uuid(context),
expected_generation=getattr(context, 'placement_generation', None),
)
except Exception:
return False
return True
async def _cleanup_expired_uploaded_files(
self,
context: ExecutionContext,
retention_days: int,
) -> int:
provider = self.ap.storage_mgr.storage_provider
provider_name = provider.__class__.__name__
if provider_name == 'LocalStorageProvider':
candidates = self._expired_local_upload_candidates(retention_days, include_paths=True)
candidates = self._expired_local_upload_candidates(
context,
retention_days,
include_paths=True,
)
deleted = 0
for item in candidates:
try:
@@ -125,47 +158,65 @@ class MaintenanceService:
return deleted
if provider_name == 'S3StorageProvider':
return await self._cleanup_expired_s3_uploaded_files(retention_days)
return await self._cleanup_expired_s3_uploaded_files(context, retention_days)
return 0
async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]:
async def _expired_uploaded_candidates(
self,
context: TenantContext,
retention_days: int,
) -> list[dict[str, Any]]:
provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__
if provider_name == 'LocalStorageProvider':
return self._expired_local_upload_candidates(retention_days)
return self._expired_local_upload_candidates(context, retention_days)
if provider_name == 'S3StorageProvider':
return await self._expired_s3_upload_candidates(retention_days)
return await self._expired_s3_upload_candidates(context, retention_days)
return []
async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int:
async def _cleanup_expired_s3_uploaded_files(
self,
context: ExecutionContext,
retention_days: int,
) -> int:
provider = self.ap.storage_mgr.storage_provider
candidates = await self._expired_s3_upload_candidates(retention_days)
candidates = await self._expired_s3_upload_candidates(context, retention_days)
deleted = 0
for item in candidates:
await provider.delete(item['key'])
deleted += 1
return deleted
async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]:
async def _expired_s3_upload_candidates(
self,
context: TenantContext,
retention_days: int,
) -> list[dict[str, Any]]:
provider = self.ap.storage_mgr.storage_provider
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days)
candidates = []
paginator = provider.s3_client.get_paginator('list_objects_v2')
for page in paginator.paginate(Bucket=provider.bucket_name):
for obj in page.get('Contents', []):
key = obj.get('Key', '')
last_modified = obj.get('LastModified')
if not self._is_uploaded_file_key(key):
continue
if last_modified and last_modified < cutoff:
candidates.append(
{
'key': key,
'size_bytes': obj.get('Size', 0),
'modified_at': last_modified.isoformat(),
}
)
seen_prefixes: set[str] = set()
for owner_type in UPLOAD_OWNER_TYPES:
prefix = self.ap.storage_mgr.scoped_prefix(context, owner_type=owner_type)
if prefix in seen_prefixes:
continue
seen_prefixes.add(prefix)
for page in paginator.paginate(Bucket=provider.bucket_name, Prefix=prefix):
for obj in page.get('Contents', []):
key = obj.get('Key', '')
last_modified = obj.get('LastModified')
if not self._is_uploaded_file_key(context, key):
continue
if last_modified and last_modified < cutoff:
candidates.append(
{
'key': key,
'size_bytes': obj.get('Size', 0),
'modified_at': last_modified.isoformat(),
}
)
return candidates
@@ -182,28 +233,39 @@ class MaintenanceService:
return deleted
def _expired_local_upload_candidates(
self, retention_days: int, include_paths: bool = False
self,
context: TenantContext,
retention_days: int,
include_paths: bool = False,
) -> list[dict[str, Any]]:
storage_root = Path('data/storage')
if not storage_root.exists():
return []
cutoff = datetime.datetime.now().timestamp() - retention_days * 86400
candidates = []
for entry in storage_root.iterdir():
if not entry.is_file() or not self._is_uploaded_file_key(entry.name):
seen_roots: set[Path] = set()
for owner_type in UPLOAD_OWNER_TYPES:
scoped_root = storage_root / self.ap.storage_mgr.scoped_prefix(context, owner_type=owner_type)
if scoped_root in seen_roots:
continue
stat = entry.stat()
if stat.st_mtime >= cutoff:
seen_roots.add(scoped_root)
if not scoped_root.exists():
continue
item = {
'key': entry.name,
'size_bytes': stat.st_size,
'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(),
}
if include_paths:
item['path'] = str(entry)
candidates.append(item)
for entry in scoped_root.rglob('*'):
if not entry.is_file():
continue
stat = entry.stat()
if stat.st_mtime >= cutoff:
continue
item = {
'key': entry.relative_to(storage_root).as_posix(),
'size_bytes': stat.st_size,
'modified_at': datetime.datetime.fromtimestamp(
stat.st_mtime,
datetime.timezone.utc,
).isoformat(),
}
if include_paths:
item['path'] = str(entry)
candidates.append(item)
return candidates
def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]:
@@ -236,33 +298,51 @@ class MaintenanceService:
candidates.append(item)
return candidates
def _is_uploaded_file_key(self, key: str) -> bool:
return '/' not in key and not key.startswith('plugin_config_')
def _is_uploaded_file_key(self, context: TenantContext, key: str) -> bool:
return any(
key.startswith(self.ap.storage_mgr.scoped_prefix(context, owner_type=owner_type))
and self.ap.storage_mgr.is_scoped_object_key(key, expected_owner_type=owner_type)
for owner_type in UPLOAD_OWNER_TYPES
)
async def _monitoring_counts(self) -> dict[str, int]:
async def _monitoring_counts(self, context: TenantContext) -> dict[str, int]:
workspace_uuid = require_workspace_uuid(context)
tables = {
'messages': persistence_monitoring.MonitoringMessage.id,
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
'tool_calls': persistence_monitoring.MonitoringToolCall.id,
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
'errors': persistence_monitoring.MonitoringError.id,
'sessions': persistence_monitoring.MonitoringSession.session_id,
'feedback': persistence_monitoring.MonitoringFeedback.id,
'messages': (persistence_monitoring.MonitoringMessage, persistence_monitoring.MonitoringMessage.id),
'llm_calls': (persistence_monitoring.MonitoringLLMCall, persistence_monitoring.MonitoringLLMCall.id),
'tool_calls': (persistence_monitoring.MonitoringToolCall, persistence_monitoring.MonitoringToolCall.id),
'embedding_calls': (
persistence_monitoring.MonitoringEmbeddingCall,
persistence_monitoring.MonitoringEmbeddingCall.id,
),
'errors': (persistence_monitoring.MonitoringError, persistence_monitoring.MonitoringError.id),
'sessions': (
persistence_monitoring.MonitoringSession,
persistence_monitoring.MonitoringSession.session_id,
),
'feedback': (persistence_monitoring.MonitoringFeedback, persistence_monitoring.MonitoringFeedback.id),
}
counts: dict[str, int] = {}
for key, column in tables.items():
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column)))
for key, (model, column) in tables.items():
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(column)).where(model.workspace_uuid == workspace_uuid)
)
counts[key] = result.scalar() or 0
return counts
async def _binary_storage_stats(self) -> dict[str, Any]:
async def _binary_storage_stats(self, context: TenantContext) -> dict[str, Any]:
workspace_uuid = require_workspace_uuid(context)
count_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key))
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key)).where(
persistence_bstorage.BinaryStorage.workspace_uuid == workspace_uuid
)
)
size_bytes = None
try:
size_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value)))
sqlalchemy.select(
sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value))
).where(persistence_bstorage.BinaryStorage.workspace_uuid == workspace_uuid)
)
size_bytes = size_result.scalar() or 0
except Exception as e:
+357 -142
View File
@@ -1,198 +1,417 @@
from __future__ import annotations
import sqlalchemy
import uuid
import asyncio
import copy
import re
import uuid
from ....core import app
import sqlalchemy
from ....core import app, taskmgr
from ....entity.persistence import mcp as persistence_mcp
from ....core import taskmgr
from ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus
from ....entity.persistence import plugin as persistence_plugin
from ....provider.tools.loaders.mcp import MCPSessionStatus, RuntimeMCPSession
from ....workspace.errors import WorkspaceNotFoundError
from ..context import ExecutionContext
from .secrets import is_url_key, redact_url_secrets, restore_url_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
_SECRET_MASK = '***'
_MISSING_SECRET = object()
_SENSITIVE_CONFIG_NAMES = frozenset(
{
'api_key',
'apikey',
'auth',
'authorization',
'cookie',
'credentials',
'database_url',
'dsn',
'key',
'proxy_authorization',
'set_cookie',
}
)
_SENSITIVE_CONFIG_TOKENS = frozenset(
{
'credential',
'credentials',
'passwd',
'password',
'secret',
'token',
}
)
_SENSITIVE_KEY_QUALIFIERS = frozenset(
{
'access',
'api',
'auth',
'bearer',
'client',
'debug',
'encryption',
'private',
'signing',
}
)
def _normalize_config_key(key: object) -> str:
value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
def _is_sensitive_config_key(key: object) -> bool:
normalized = _normalize_config_key(key)
if normalized in _SENSITIVE_CONFIG_NAMES:
return True
tokens = frozenset(token for token in normalized.split('_') if token)
if tokens & _SENSITIVE_CONFIG_TOKENS:
return True
return 'key' in tokens and bool(tokens & _SENSITIVE_KEY_QUALIFIERS)
def _mask_secret_structure(value):
if isinstance(value, dict):
return {key: _mask_secret_structure(item) for key, item in value.items()}
if isinstance(value, list):
return [_mask_secret_structure(item) for item in value]
if isinstance(value, tuple):
return tuple(_mask_secret_structure(item) for item in value)
if value is None or value == '':
return value
return _SECRET_MASK
def redact_mcp_secrets(value):
"""Return a recursively redacted copy of MCP configuration data."""
if isinstance(value, dict):
return {
key: (
_mask_secret_structure(item)
if _is_sensitive_config_key(key)
else redact_url_secrets(item)
if is_url_key(key)
else redact_mcp_secrets(item)
)
for key, item in value.items()
}
if isinstance(value, list):
return [redact_mcp_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_mcp_secrets(item) for item in value)
return value
def restore_mcp_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
"""Restore masked leaves from the current MCP config before a write."""
if sensitive and value == _SECRET_MASK:
if current_value is _MISSING_SECRET:
raise ValueError('Masked MCP secret has no existing value')
return copy.deepcopy(current_value)
if isinstance(value, dict):
current_mapping = current_value if isinstance(current_value, dict) else {}
return {
key: (
restore_url_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
)
if not sensitive and not _is_sensitive_config_key(key) and is_url_key(key)
else restore_mcp_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
sensitive=sensitive or _is_sensitive_config_key(key),
)
)
for key, item in value.items()
}
if isinstance(value, list):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return [
restore_mcp_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
]
if isinstance(value, tuple):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return tuple(
restore_mcp_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
)
return value
class MCPService:
"""Workspace-scoped MCP configuration and runtime facade."""
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_runtime_info(self, server_name: str) -> dict | None:
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
if session:
return session.get_runtime_info_dict()
return None
async def _execution_context(self, context: TenantContext) -> ExecutionContext:
workspace_uuid = require_workspace_uuid(context)
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
generation = getattr(context, 'placement_generation', None)
if not instance_uuid or not isinstance(generation, int) or isinstance(generation, bool) or generation <= 0:
raise ValueError('MCP operations require an explicit fenced execution context')
binding = await self.ap.workspace_service.get_execution_binding(
workspace_uuid,
expected_generation=generation,
)
if binding.instance_uuid != instance_uuid:
raise ValueError('MCP execution context belongs to another LangBot instance')
return ExecutionContext(
instance_uuid=instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=generation,
bot_uuid=getattr(context, 'bot_uuid', None),
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
query_uuid=getattr(context, 'query_uuid', None),
)
async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
async def get_runtime_info(self, context: TenantContext, server_name: str) -> dict | None:
execution_context = await self._execution_context(context)
session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
return session.get_runtime_info_dict() if session else None
servers = result.all()
async def get_mcp_servers(self, context: TenantContext, contain_runtime_info: bool = False) -> list[dict]:
execution_context = await self._execution_context(context)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(sqlalchemy.select(persistence_mcp.MCPServer), persistence_mcp.MCPServer, context)
)
serialized_servers = [
self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers
redact_mcp_secrets(self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server))
for server in result.all()
]
if contain_runtime_info:
for server in serialized_servers:
runtime_info = await self.get_runtime_info(server['name'])
server['runtime_info'] = runtime_info if runtime_info else None
session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server['name'])
server['runtime_info'] = session.get_runtime_info_dict() if session else None
return serialized_servers
async def create_mcp_server(self, server_data: dict) -> str:
# Check limitation (extensions = MCP servers + plugins)
async def create_mcp_server(self, context: TenantContext, server_data: dict) -> str:
execution_context = await self._execution_context(context)
workspace_uuid = execution_context.workspace_uuid
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_extensions = limitation.get('max_extensions', -1)
if max_extensions >= 0:
existing_mcp_servers = await self.get_mcp_servers()
plugins = await self.ap.plugin_connector.list_plugins()
total_extensions = len(existing_mcp_servers) + len(plugins)
if total_extensions >= max_extensions:
mcp_count_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(persistence_mcp.MCPServer.uuid)).where(
persistence_mcp.MCPServer.workspace_uuid == workspace_uuid
)
)
plugin_count_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.workspace_uuid == workspace_uuid)
)
if (mcp_count_result.scalar() or 0) + (plugin_count_result.scalar() or 0) >= max_extensions:
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
server_name = str(server_data.get('name') or '').strip()
payload = dict(server_data)
payload.pop('workspace_uuid', None)
server_name = str(payload.get('name') or '').strip()
if not server_name:
raise ValueError('MCP server name is required')
server_data['name'] = server_name
payload['name'] = server_name
payload['workspace_uuid'] = workspace_uuid
payload['uuid'] = str(uuid.uuid4())
existing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
sqlalchemy.select(persistence_mcp.MCPServer).where(
persistence_mcp.MCPServer.workspace_uuid == workspace_uuid,
persistence_mcp.MCPServer.name == server_name,
)
)
if existing_result.first() is not None:
raise ValueError(f'MCP server already exists: {server_name}')
server_data['uuid'] = str(uuid.uuid4())
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(payload))
created = await self._get_mcp_server_by_uuid_raw(execution_context, payload['uuid'])
if created and self.ap.tool_mgr.mcp_tool_loader:
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(execution_context, created))
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
return payload['uuid']
async def get_mcp_server_by_uuid(self, context: TenantContext, server_uuid: str) -> dict | None:
execution_context = await self._execution_context(context)
server_data = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
return redact_mcp_secrets(server_data) if server_data is not None else None
async def _get_mcp_server_by_uuid_raw(
self,
execution_context: ExecutionContext,
server_uuid: str,
) -> dict | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid'])
scope_statement(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid),
persistence_mcp.MCPServer,
execution_context,
)
)
server_entity = result.first()
if server_entity:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
if self.ap.tool_mgr.mcp_tool_loader:
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
server = result.first()
return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) if server else None
return server_data['uuid']
async def get_mcp_server_by_name(self, context: TenantContext, server_name: str) -> dict | None:
execution_context = await self._execution_context(context)
server_data = await self._get_mcp_server_by_name_raw(execution_context, server_name)
if server_data is None:
return None
session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
response_data = {
**server_data,
'runtime_info': session.get_runtime_info_dict() if session else None,
}
return redact_mcp_secrets(response_data)
async def get_mcp_server_by_name(self, server_name: str) -> dict | None:
async def _get_mcp_server_by_name_raw(
self,
execution_context: ExecutionContext,
server_name: str,
) -> dict | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
scope_statement(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name),
persistence_mcp.MCPServer,
execution_context,
)
)
server = result.first()
if server is None:
return None
return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
runtime_info = await self.get_runtime_info(server.name)
server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
server_data['runtime_info'] = runtime_info if runtime_info else None
return server_data
async def update_mcp_server(self, context: TenantContext, server_uuid: str, server_data: dict) -> None:
execution_context = await self._execution_context(context)
old_server = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
if old_server is None:
raise WorkspaceNotFoundError('MCP server not found')
payload = dict(server_data)
payload.pop('uuid', None)
payload.pop('workspace_uuid', None)
payload = restore_mcp_secret_placeholders(payload, old_server)
if 'name' in payload:
payload['name'] = str(payload['name'] or '').strip()
if not payload['name']:
raise ValueError('MCP server name is required')
duplicate = await self._get_mcp_server_by_name_raw(execution_context, payload['name'])
if duplicate is not None and duplicate['uuid'] != server_uuid:
raise ValueError(f'MCP server already exists: {payload["name"]}')
async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
scope_statement(
sqlalchemy.update(persistence_mcp.MCPServer)
.where(persistence_mcp.MCPServer.uuid == server_uuid)
.values(payload),
persistence_mcp.MCPServer,
execution_context,
)
)
old_server = result.first()
old_server_name = old_server.name if old_server else None
old_enable = old_server.enable if old_server else False
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('MCP server not found')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_mcp.MCPServer)
.where(persistence_mcp.MCPServer.uuid == server_uuid)
.values(server_data)
)
loader = self.ap.tool_mgr.mcp_tool_loader
if loader is None:
return
old_name = old_server['name']
old_enable = bool(old_server['enable'])
updated = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
if updated is None:
raise WorkspaceNotFoundError('MCP server not found')
new_enable = bool(updated['enable'])
if old_enable and loader.has_session(execution_context, old_name):
await loader.remove_mcp_server(execution_context, old_name)
if new_enable:
task = asyncio.create_task(loader.host_mcp_server(execution_context, updated))
loader._hosted_mcp_tasks.append(task)
if self.ap.tool_mgr.mcp_tool_loader:
new_enable = server_data.get('enable', False)
need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions
if old_enable and not new_enable:
if need_remove:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
elif not old_enable and new_enable:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
)
updated_server = result.first()
if updated_server:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
elif old_enable and new_enable:
if need_remove:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
)
updated_server = result.first()
if updated_server:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
async def delete_mcp_server(self, server_uuid: str) -> None:
async def delete_mcp_server(self, context: TenantContext, server_uuid: str) -> None:
execution_context = await self._execution_context(context)
server = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
if server is None:
raise WorkspaceNotFoundError('MCP server not found')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
scope_statement(
sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid),
persistence_mcp.MCPServer,
execution_context,
)
)
server = result.first()
server_name = server.name if server else None
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('MCP server not found')
loader = self.ap.tool_mgr.mcp_tool_loader
if loader and loader.has_session(execution_context, server['name']):
await loader.remove_mcp_server(execution_context, server['name'])
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
)
async def _require_server(self, context: TenantContext, server_name: str) -> tuple[ExecutionContext, dict]:
execution_context = await self._execution_context(context)
server = await self._get_mcp_server_by_name_raw(execution_context, server_name)
if server is None:
raise WorkspaceNotFoundError('MCP server not found')
return execution_context, server
if server_name and self.ap.tool_mgr.mcp_tool_loader:
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
async def get_mcp_server_resources(self, context: TenantContext, server_name: str) -> list[dict]:
execution_context, _ = await self._require_server(context, server_name)
return await self.ap.tool_mgr.mcp_tool_loader.get_resources(execution_context, server_name)
async def get_mcp_server_resources(self, server_name: str) -> list[dict]:
"""Get resources from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.get_resources(server_name)
async def get_mcp_server_resource_templates(self, server_name: str) -> list[dict]:
"""Get resource templates from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(server_name)
async def get_mcp_server_resource_templates(self, context: TenantContext, server_name: str) -> list[dict]:
execution_context, _ = await self._require_server(context, server_name)
return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(execution_context, server_name)
async def read_mcp_server_resource_envelope(
self,
context: TenantContext,
server_name: str,
uri: str,
*,
max_bytes: int | None = None,
include_blob: bool = False,
) -> dict:
"""Read a resource from a specific MCP server with metadata."""
execution_context, _ = await self._require_server(context, server_name)
kwargs = {'include_blob': include_blob, 'source': 'ui_preview'}
if max_bytes is not None:
kwargs['max_bytes'] = max_bytes
return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(server_name, uri, **kwargs)
return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(
execution_context,
server_name,
uri,
**kwargs,
)
async def read_mcp_server_resource(self, server_name: str, uri: str) -> list[dict]:
"""Read a resource from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.read_resource(server_name, uri)
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
"""测试 MCP 服务器连接并返回任务 ID"""
async def read_mcp_server_resource(self, context: TenantContext, server_name: str, uri: str) -> list[dict]:
execution_context, _ = await self._require_server(context, server_name)
return await self.ap.tool_mgr.mcp_tool_loader.read_resource(execution_context, server_name, uri)
async def test_mcp_server(self, context: TenantContext, server_name: str, server_data: dict) -> int:
execution_context = await self._execution_context(context)
runtime_mcp_session: RuntimeMCPSession | None = None
ctx = taskmgr.TaskContext.new()
if server_name != '_':
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
await self._require_server(execution_context, server_name)
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
if runtime_mcp_session is None:
raise ValueError(f'Server not found: {server_name}')
raise WorkspaceNotFoundError('MCP server not found')
persisted_session = runtime_mcp_session
async def _refresh_and_report() -> None:
# Testing a persisted server should REUSE its live shared-session
# process, not rebuild it. Try a lightweight refresh (a real
# list_tools probe over the existing connection) first; only fall
# back to a full start() when the session has no live connection
# to probe (never connected, or the process is actually gone).
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
if needs_start:
await persisted_session.start()
@@ -200,30 +419,23 @@ class MCPService:
try:
await persisted_session.refresh()
except Exception:
# The live connection was stale/dropped: reconnect once
# (reusing the live managed process where possible) and
# re-probe, instead of reporting a false failure.
await persisted_session.start()
# Surface the discovered tools so the config page can render them
# even for an already-hosted server.
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
coroutine = _refresh_and_report()
else:
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
# A transient test owns an isolated Box session. Always tear it down
# after the test completes (success or failure) so it does not leak.
payload = dict(server_data)
payload.pop('workspace_uuid', None)
payload['workspace_uuid'] = execution_context.workspace_uuid
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(
execution_context,
payload,
)
test_session = runtime_mcp_session
async def _run_and_cleanup() -> None:
try:
await test_session.start()
# Capture the runtime info (status + discovered tools) BEFORE
# shutting the transient session down. The create/edit config
# page has no persisted server to reload from, so without this
# a successful test could only show "no tools found". The
# frontend reads ctx.metadata.runtime_info to render the tools.
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
finally:
try:
@@ -239,24 +451,27 @@ class MCPService:
wrapper = self.ap.task_mgr.create_user_task(
coroutine,
kind='mcp-operation',
name=f'mcp-test-{server_name}',
name=f'mcp-test-{execution_context.workspace_uuid}-{server_name}',
label=f'Testing MCP server {server_name}',
context=ctx,
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
)
return wrapper.id
async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]:
"""Get recent log lines captured from the MCP server's stderr."""
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
async def get_mcp_server_logs(
self,
context: TenantContext,
server_name: str,
limit: int = 200,
level: str | None = None,
) -> list[dict]:
execution_context, _ = await self._require_server(context, server_name)
session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
if not session:
return []
# Get logs from the session's buffer
logs = list(session._log_buffer)
# Filter by level if specified
if level:
logs = [log for log in logs if log.get('level') == level]
# Return the most recent 'limit' logs
return logs[-limit:]
+441 -158
View File
@@ -9,6 +9,9 @@ from ....core import app
from ....entity.persistence import model as persistence_model
from ....entity.persistence import pipeline as persistence_pipeline
from ....provider.modelmgr import requester as model_requester
from ....workspace.errors import WorkspaceNotFoundError
from .secrets import mask_secret_value, redact_secrets, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
def _parse_provider_api_keys(provider_dict: dict) -> dict:
@@ -34,7 +37,29 @@ def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
return {**model_data, 'uuid': model_uuid}
async def _validate_provider_supports(ap: app.Application, provider_uuid: str, model_type: str) -> None:
def _redact_model_secrets(model_data: dict) -> dict:
"""Return a copy with model args and embedded provider credentials masked."""
redacted = model_data.copy()
if 'extra_args' in redacted:
redacted['extra_args'] = redact_secrets(redacted['extra_args'])
if isinstance(redacted.get('provider'), dict):
provider = redacted['provider'].copy()
# ModelProvider never contains another provider. Dropping this key also
# makes the serializer robust to a reused/self-referential test double.
provider.pop('provider', None)
if 'api_keys' in provider:
provider['api_keys'] = mask_secret_value(provider['api_keys'])
redacted['provider'] = provider
return redacted
async def _validate_provider_supports(
ap: app.Application,
context: TenantContext,
provider_uuid: str,
model_type: str,
) -> None:
"""Validate that the provider's requester declares support for ``model_type``.
``model_type`` is one of the manifest ``support_type`` values:
@@ -47,11 +72,12 @@ async def _validate_provider_supports(ap: app.Application, provider_uuid: str, m
if model_mgr is None:
return
provider_dict = getattr(model_mgr, 'provider_dict', None)
if not provider_dict:
get_provider = getattr(model_mgr, 'get_provider_by_uuid', None)
if not callable(get_provider):
return
runtime_provider = provider_dict.get(provider_uuid)
if runtime_provider is None:
try:
runtime_provider = await get_provider(context, provider_uuid)
except ValueError:
return
requester_name = getattr(getattr(runtime_provider, 'provider_entity', None), 'requester', None)
@@ -74,20 +100,48 @@ async def _validate_provider_supports(ap: app.Application, provider_uuid: str, m
raise ValueError(f'Provider requester "{requester_name}" does not support {model_type} models')
async def _require_workspace_provider(
ap: app.Application,
context: TenantContext,
provider_uuid: str,
) -> dict:
"""Require the referenced provider to belong to the active Workspace."""
provider = await ap.provider_service.get_provider(context, provider_uuid)
if provider is None:
raise WorkspaceNotFoundError('Provider not found')
return provider
async def _require_runtime_provider(
ap: app.Application,
context: TenantContext,
provider_uuid: str,
) -> model_requester.RuntimeProvider:
try:
return await ap.model_mgr.get_provider_by_uuid(context, provider_uuid)
except ValueError as exc:
raise Exception('provider not found') from exc
class LLMModelsService:
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_llm_models(self, include_secret: bool = True) -> list[dict]:
async def get_llm_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
"""Get all LLM models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
result = await self.ap.persistence_mgr.execute_async(
scope_statement(sqlalchemy.select(persistence_model.LLMModel), persistence_model.LLMModel, context)
)
models = result.all()
# Get all providers for lookup
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider), persistence_model.ModelProvider, context
)
)
providers = {p.uuid: p for p in providers_result.all()}
@@ -98,29 +152,50 @@ class LLMModelsService:
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
provider_dict = _parse_provider_api_keys(provider_dict)
if not include_secret:
provider_dict['api_keys'] = ['***'] * len(provider_dict.get('api_keys', []))
model_dict['provider'] = provider_dict
if not include_secret:
model_dict = _redact_model_secrets(model_dict)
models_list.append(model_dict)
return models_list
async def get_llm_models_by_provider(self, provider_uuid: str) -> list[dict]:
async def get_llm_models_by_provider(
self,
context: TenantContext,
provider_uuid: str,
*,
include_secret: bool = False,
) -> list[dict]:
"""Get LLM models by provider UUID"""
await _require_workspace_provider(self.ap, context, provider_uuid)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
scope_statement(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
),
persistence_model.LLMModel,
context,
)
)
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
async def create_llm_model(
self, model_data: dict, preserve_uuid: bool = False, auto_set_to_default_pipeline: bool = True
self,
context: TenantContext,
model_data: dict,
preserve_uuid: bool = False,
auto_set_to_default_pipeline: bool = True,
) -> str:
"""Create a new LLM model"""
workspace_uuid = require_workspace_uuid(context)
model_data = model_data.copy()
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
model_data['workspace_uuid'] = workspace_uuid
if 'extra_args' in model_data:
model_data['extra_args'] = restore_secret_placeholders(model_data['extra_args'])
# Handle provider creation if needed
if 'provider' in model_data:
@@ -130,31 +205,35 @@ class LLMModelsService:
else:
# Create new provider
provider_uuid = await self.ap.provider_service.find_or_create_provider(
context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'llm')
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
context,
persistence_model.LLMModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
if auto_set_to_default_pipeline:
# set the default pipeline model to this model
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
scope_statement(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
),
persistence_pipeline.LegacyPipeline,
workspace_uuid,
)
)
pipeline = result.first()
@@ -167,14 +246,23 @@ class LLMModelsService:
'fallbacks': [],
}
pipeline_data = {'config': pipeline_config}
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
await self.ap.pipeline_service.update_pipeline(context, pipeline.uuid, pipeline_data)
return model_data['uuid']
async def get_llm_model(self, model_uuid: str) -> dict | None:
async def get_llm_model(
self,
context: TenantContext,
model_uuid: str,
include_secret: bool = False,
) -> dict | None:
"""Get a single LLM model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
scope_statement(
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
persistence_model.LLMModel,
context,
)
)
model = result.first()
if model is None:
@@ -184,21 +272,38 @@ class LLMModelsService:
# Get provider
provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
),
persistence_model.ModelProvider,
context,
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
provider_dict = _parse_provider_api_keys(provider_dict)
model_dict['provider'] = provider_dict
if not include_secret:
model_dict = _redact_model_secrets(model_dict)
return model_dict
async def update_llm_model(self, model_uuid: str, model_data: dict) -> None:
async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing LLM model"""
if 'uuid' in model_data:
del model_data['uuid']
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
if existing_model is None:
raise WorkspaceNotFoundError('Model not found')
model_data = model_data.copy()
model_data.pop('uuid', None)
model_data.pop('workspace_uuid', None)
if 'extra_args' in model_data:
model_data['extra_args'] = restore_secret_placeholders(
model_data['extra_args'],
existing_model.get('extra_args', {}),
)
# Handle provider update if needed
if 'provider' in model_data:
@@ -207,50 +312,71 @@ class LLMModelsService:
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.LLMModel)
.where(persistence_model.LLMModel.uuid == model_uuid)
.values(**model_data)
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
await _require_workspace_provider(self.ap, context, provider_uuid)
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(persistence_model.LLMModel)
.where(persistence_model.LLMModel.uuid == model_uuid)
.values(**model_data),
persistence_model.LLMModel,
context,
)
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Model not found')
await self.ap.model_mgr.remove_llm_model(model_uuid)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
await self.ap.model_mgr.remove_llm_model(context, model_uuid)
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)),
context,
persistence_model.LLMModel(
**_runtime_model_data(
model_uuid,
{
key: value
for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
if key not in {'provider', 'created_at', 'updated_at'}
},
)
),
runtime_provider,
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
async def delete_llm_model(self, model_uuid: str) -> None:
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete an LLM model"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
persistence_model.LLMModel,
context,
)
)
await self.ap.model_mgr.remove_llm_model(model_uuid)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Model not found')
await self.ap.model_mgr.remove_llm_model(context, model_uuid)
async def test_llm_model(self, model_uuid: str, model_data: dict) -> None:
async def test_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Test an LLM model"""
require_workspace_uuid(context)
runtime_llm_model: model_requester.RuntimeLLMModel | None = None
if model_uuid != '_':
for model in self.ap.model_mgr.llm_models:
if model.model_entity.uuid == model_uuid:
runtime_llm_model = model
break
if runtime_llm_model is None:
raise Exception('model not found')
if await self.get_llm_model(context, model_uuid) is None:
raise WorkspaceNotFoundError('Model not found')
runtime_llm_model = await self.ap.model_mgr.get_model_by_uuid(context, model_uuid)
else:
runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(model_data)
runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(context, model_data)
extra_args = model_data.get('extra_args', {})
await runtime_llm_model.provider.invoke_llm(
@@ -259,6 +385,7 @@ class LLMModelsService:
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
funcs=[],
extra_args=extra_args,
execution_context=runtime_llm_model.execution_context,
)
@@ -268,13 +395,19 @@ class EmbeddingModelsService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_embedding_models(self) -> list[dict]:
async def get_embedding_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
"""Get all embedding models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.EmbeddingModel), persistence_model.EmbeddingModel, context
)
)
models = result.all()
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider), persistence_model.ModelProvider, context
)
)
providers = {p.uuid: p for p in providers_result.all()}
@@ -284,25 +417,46 @@ class EmbeddingModelsService:
provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
provider_dict = _parse_provider_api_keys(provider_dict)
model_dict['provider'] = provider_dict
if not include_secret:
model_dict = _redact_model_secrets(model_dict)
models_list.append(model_dict)
return models_list
async def get_embedding_models_by_provider(self, provider_uuid: str) -> list[dict]:
async def get_embedding_models_by_provider(
self,
context: TenantContext,
provider_uuid: str,
*,
include_secret: bool = False,
) -> list[dict]:
"""Get embedding models by provider UUID"""
await _require_workspace_provider(self.ap, context, provider_uuid)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
scope_statement(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
),
persistence_model.EmbeddingModel,
context,
)
)
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models]
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models]
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
async def create_embedding_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
async def create_embedding_model(
self, context: TenantContext, model_data: dict, preserve_uuid: bool = False
) -> str:
"""Create a new embedding model"""
model_data = model_data.copy()
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
model_data['workspace_uuid'] = require_workspace_uuid(context)
if 'extra_args' in model_data:
model_data['extra_args'] = restore_secret_placeholders(model_data['extra_args'])
if 'provider' in model_data:
provider_data = model_data.pop('provider')
@@ -310,35 +464,44 @@ class EmbeddingModelsService:
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'text-embedding')
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)
)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
context,
persistence_model.EmbeddingModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
await self.ap.model_mgr.cache_embedding_model(context, runtime_embedding_model)
return model_data['uuid']
async def get_embedding_model(self, model_uuid: str) -> dict | None:
async def get_embedding_model(
self,
context: TenantContext,
model_uuid: str,
include_secret: bool = False,
) -> dict | None:
"""Get a single embedding model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.uuid == model_uuid
scope_statement(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.uuid == model_uuid
),
persistence_model.EmbeddingModel,
context,
)
)
model = result.first()
@@ -348,21 +511,38 @@ class EmbeddingModelsService:
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)
provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
),
persistence_model.ModelProvider,
context,
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
provider_dict = _parse_provider_api_keys(provider_dict)
model_dict['provider'] = provider_dict
if not include_secret:
model_dict = _redact_model_secrets(model_dict)
return model_dict
async def update_embedding_model(self, model_uuid: str, model_data: dict) -> None:
async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing embedding model"""
if 'uuid' in model_data:
del model_data['uuid']
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
if existing_model is None:
raise WorkspaceNotFoundError('Model not found')
model_data = model_data.copy()
model_data.pop('uuid', None)
model_data.pop('workspace_uuid', None)
if 'extra_args' in model_data:
model_data['extra_args'] = restore_secret_placeholders(
model_data['extra_args'],
existing_model.get('extra_args', {}),
)
if 'provider' in model_data:
provider_data = model_data.pop('provider')
@@ -370,57 +550,82 @@ class EmbeddingModelsService:
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.EmbeddingModel)
.where(persistence_model.EmbeddingModel.uuid == model_uuid)
.values(**model_data)
)
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
await _require_workspace_provider(self.ap, context, provider_uuid)
await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
await self.ap.model_mgr.remove_embedding_model(model_uuid)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)),
runtime_provider,
)
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
async def delete_embedding_model(self, model_uuid: str) -> None:
"""Delete an embedding model"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.uuid == model_uuid
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(persistence_model.EmbeddingModel)
.where(persistence_model.EmbeddingModel.uuid == model_uuid)
.values(**model_data),
persistence_model.EmbeddingModel,
context,
)
)
await self.ap.model_mgr.remove_embedding_model(model_uuid)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Model not found')
async def test_embedding_model(self, model_uuid: str, model_data: dict) -> None:
await self.ap.model_mgr.remove_embedding_model(context, model_uuid)
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
context,
persistence_model.EmbeddingModel(
**_runtime_model_data(
model_uuid,
{
key: value
for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
if key not in {'provider', 'created_at', 'updated_at'}
},
)
),
runtime_provider,
)
await self.ap.model_mgr.cache_embedding_model(context, runtime_embedding_model)
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete an embedding model"""
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.uuid == model_uuid
),
persistence_model.EmbeddingModel,
context,
)
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Model not found')
await self.ap.model_mgr.remove_embedding_model(context, model_uuid)
async def test_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Test an embedding model"""
require_workspace_uuid(context)
runtime_embedding_model: model_requester.RuntimeEmbeddingModel | None = None
if model_uuid != '_':
for model in self.ap.model_mgr.embedding_models:
if model.model_entity.uuid == model_uuid:
runtime_embedding_model = model
break
if runtime_embedding_model is None:
raise Exception('model not found')
if await self.get_embedding_model(context, model_uuid) is None:
raise WorkspaceNotFoundError('Model not found')
runtime_embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid(context, model_uuid)
else:
runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(model_data)
runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(
context,
model_data,
)
await runtime_embedding_model.provider.invoke_embedding(
model=runtime_embedding_model,
input_text=['Hello, world!'],
extra_args={},
execution_context=runtime_embedding_model.execution_context,
)
@@ -430,13 +635,17 @@ class RerankModelsService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_rerank_models(self) -> list[dict]:
async def get_rerank_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
"""Get all rerank models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
result = await self.ap.persistence_mgr.execute_async(
scope_statement(sqlalchemy.select(persistence_model.RerankModel), persistence_model.RerankModel, context)
)
models = result.all()
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider), persistence_model.ModelProvider, context
)
)
providers = {p.uuid: p for p in providers_result.all()}
@@ -446,25 +655,44 @@ class RerankModelsService:
provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
provider_dict = _parse_provider_api_keys(provider_dict)
model_dict['provider'] = provider_dict
if not include_secret:
model_dict = _redact_model_secrets(model_dict)
models_list.append(model_dict)
return models_list
async def get_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]:
async def get_rerank_models_by_provider(
self,
context: TenantContext,
provider_uuid: str,
*,
include_secret: bool = False,
) -> list[dict]:
"""Get rerank models by provider UUID"""
await _require_workspace_provider(self.ap, context, provider_uuid)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
scope_statement(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
),
persistence_model.RerankModel,
context,
)
)
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
async def create_rerank_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new rerank model"""
model_data = model_data.copy()
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
model_data['workspace_uuid'] = require_workspace_uuid(context)
if 'extra_args' in model_data:
model_data['extra_args'] = restore_secret_placeholders(model_data['extra_args'])
if 'provider' in model_data:
provider_data = model_data.pop('provider')
@@ -472,34 +700,45 @@ class RerankModelsService:
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'rerank')
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
context,
persistence_model.RerankModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
await self.ap.model_mgr.cache_rerank_model(context, runtime_rerank_model)
return model_data['uuid']
async def get_rerank_model(self, model_uuid: str) -> dict | None:
async def get_rerank_model(
self,
context: TenantContext,
model_uuid: str,
include_secret: bool = False,
) -> dict | None:
"""Get a single rerank model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
scope_statement(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.uuid == model_uuid
),
persistence_model.RerankModel,
context,
)
)
model = result.first()
if model is None:
@@ -508,21 +747,38 @@ class RerankModelsService:
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
),
persistence_model.ModelProvider,
context,
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
provider_dict = _parse_provider_api_keys(provider_dict)
model_dict['provider'] = provider_dict
if not include_secret:
model_dict = _redact_model_secrets(model_dict)
return model_dict
async def update_rerank_model(self, model_uuid: str, model_data: dict) -> None:
async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing rerank model"""
if 'uuid' in model_data:
del model_data['uuid']
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
if existing_model is None:
raise WorkspaceNotFoundError('Model not found')
model_data = model_data.copy()
model_data.pop('uuid', None)
model_data.pop('workspace_uuid', None)
if 'extra_args' in model_data:
model_data['extra_args'] = restore_secret_placeholders(
model_data['extra_args'],
existing_model.get('extra_args', {}),
)
if 'provider' in model_data:
provider_data = model_data.pop('provider')
@@ -530,50 +786,76 @@ class RerankModelsService:
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.RerankModel)
.where(persistence_model.RerankModel.uuid == model_uuid)
.values(**model_data)
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
await _require_workspace_provider(self.ap, context, provider_uuid)
await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(persistence_model.RerankModel)
.where(persistence_model.RerankModel.uuid == model_uuid)
.values(**model_data),
persistence_model.RerankModel,
context,
)
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Model not found')
await self.ap.model_mgr.remove_rerank_model(model_uuid)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
await self.ap.model_mgr.remove_rerank_model(context, model_uuid)
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)),
context,
persistence_model.RerankModel(
**_runtime_model_data(
model_uuid,
{
key: value
for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
if key not in {'provider', 'created_at', 'updated_at'}
},
)
),
runtime_provider,
)
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
await self.ap.model_mgr.cache_rerank_model(context, runtime_rerank_model)
async def delete_rerank_model(self, model_uuid: str) -> None:
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete a rerank model"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.RerankModel).where(
persistence_model.RerankModel.uuid == model_uuid
),
persistence_model.RerankModel,
context,
)
)
await self.ap.model_mgr.remove_rerank_model(model_uuid)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Model not found')
await self.ap.model_mgr.remove_rerank_model(context, model_uuid)
async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None:
async def test_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Test a rerank model"""
require_workspace_uuid(context)
runtime_rerank_model: model_requester.RuntimeRerankModel | None = None
if model_uuid != '_':
for model in self.ap.model_mgr.rerank_models:
if model.model_entity.uuid == model_uuid:
runtime_rerank_model = model
break
if runtime_rerank_model is None:
raise Exception('model not found')
if await self.get_rerank_model(context, model_uuid) is None:
raise WorkspaceNotFoundError('Model not found')
runtime_rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(context, model_uuid)
else:
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data)
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(
context,
model_data,
)
await runtime_rerank_model.provider.invoke_rerank(
model=runtime_rerank_model,
@@ -582,4 +864,5 @@ class RerankModelsService:
'Artificial intelligence is a branch of computer science.',
'The weather is nice today.',
],
execution_context=runtime_rerank_model.execution_context,
)
+177 -40
View File
@@ -7,6 +7,9 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import monitoring as persistence_monitoring
from ..authz import WorkspaceRequiredError
from ..context import ExecutionContext
from .tenant import TenantContext, require_workspace_uuid
class MonitoringService:
@@ -17,9 +20,26 @@ class MonitoringService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
@staticmethod
def _require_write_context(context: ExecutionContext | None) -> str:
"""Reject background/runtime writes that lost their execution fence."""
if not isinstance(context, ExecutionContext):
raise WorkspaceRequiredError('Monitoring writes require an ExecutionContext')
if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
raise WorkspaceRequiredError('Monitoring writes require an instance and Workspace')
if context.placement_generation <= 0:
raise WorkspaceRequiredError('Monitoring writes require a positive placement generation')
return context.workspace_uuid
# ========== Cleanup Methods ==========
async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]:
async def cleanup_expired_records(
self,
context: ExecutionContext,
retention_days: int,
batch_size: int = 1000,
) -> dict[str, int]:
"""Delete monitoring records older than the specified retention period.
Args:
@@ -29,6 +49,7 @@ class MonitoringService:
Returns:
A dict mapping table name to the number of deleted rows.
"""
self._require_write_context(context)
if retention_days < 1:
raise ValueError('retention_days must be >= 1')
if batch_size < 1:
@@ -87,6 +108,7 @@ class MonitoringService:
for table_name, model_cls, ts_column, pk_column in tables_and_columns:
deleted_counts[table_name] = await self._delete_expired_in_batches(
context=context,
model_cls=model_cls,
ts_column=ts_column,
pk_column=pk_column,
@@ -101,24 +123,31 @@ class MonitoringService:
async def _delete_expired_in_batches(
self,
context: ExecutionContext,
model_cls: type,
ts_column: sqlalchemy.Column,
pk_column: sqlalchemy.Column,
cutoff: datetime.datetime,
batch_size: int,
) -> int:
workspace_uuid = self._require_write_context(context)
deleted_total = 0
while True:
select_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size)
sqlalchemy.select(pk_column)
.where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
.limit(batch_size)
)
pk_values = list(select_result.scalars().all())
if not pk_values:
break
delete_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values))
sqlalchemy.delete(model_cls).where(
model_cls.workspace_uuid == workspace_uuid,
pk_column.in_(pk_values),
)
)
deleted = delete_result.rowcount or 0
deleted_total += deleted
@@ -158,13 +187,16 @@ class MonitoringService:
async def _get_message_for_tool_context(
self,
context: ExecutionContext,
message_id: str | None = None,
session_id: str | None = None,
):
workspace_uuid = self._require_write_context(context)
if message_id:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
persistence_monitoring.MonitoringMessage.id == message_id
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringMessage.id == message_id,
)
)
row = result.first()
@@ -180,6 +212,7 @@ class MonitoringService:
sqlalchemy.and_(
persistence_monitoring.MonitoringMessage.session_id == session_id,
persistence_monitoring.MonitoringMessage.role == 'user',
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
)
)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
@@ -192,7 +225,10 @@ class MonitoringService:
any_query = (
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.session_id == session_id)
.where(
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringMessage.session_id == session_id,
)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
.limit(1)
)
@@ -204,6 +240,7 @@ class MonitoringService:
async def record_message(
self,
context: ExecutionContext,
bot_id: str,
bot_name: str,
pipeline_id: str,
@@ -220,9 +257,11 @@ class MonitoringService:
role: str = 'user',
) -> str:
"""Record a message"""
workspace_uuid = self._require_write_context(context)
message_id = str(uuid.uuid4())
message_data = {
'id': message_id,
'workspace_uuid': workspace_uuid,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'bot_id': bot_id,
'bot_name': bot_name,
@@ -248,6 +287,7 @@ class MonitoringService:
async def record_llm_call(
self,
context: ExecutionContext,
bot_id: str,
bot_name: str,
pipeline_id: str,
@@ -263,9 +303,11 @@ class MonitoringService:
message_id: str | None = None,
) -> str:
"""Record an LLM call"""
workspace_uuid = self._require_write_context(context)
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
'workspace_uuid': workspace_uuid,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'model_name': model_name,
'input_tokens': input_tokens,
@@ -291,6 +333,7 @@ class MonitoringService:
async def record_tool_call(
self,
context: ExecutionContext,
tool_name: str,
tool_source: str,
duration: int,
@@ -306,7 +349,12 @@ class MonitoringService:
error_message: str | None = None,
) -> str:
"""Record a tool call."""
context_message = await self._get_message_for_tool_context(message_id=message_id, session_id=session_id)
workspace_uuid = self._require_write_context(context)
context_message = await self._get_message_for_tool_context(
context,
message_id=message_id,
session_id=session_id,
)
if context_message:
bot_id = bot_id or context_message.bot_id
bot_name = bot_name or context_message.bot_name
@@ -318,6 +366,7 @@ class MonitoringService:
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
'workspace_uuid': workspace_uuid,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'tool_name': tool_name,
'tool_source': tool_source,
@@ -342,6 +391,7 @@ class MonitoringService:
async def record_embedding_call(
self,
context: ExecutionContext,
model_name: str,
prompt_tokens: int,
total_tokens: int,
@@ -356,9 +406,11 @@ class MonitoringService:
call_type: str | None = None,
) -> str:
"""Record an embedding call"""
workspace_uuid = self._require_write_context(context)
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
'workspace_uuid': workspace_uuid,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'model_name': model_name,
'prompt_tokens': prompt_tokens,
@@ -382,6 +434,7 @@ class MonitoringService:
async def record_session_start(
self,
context: ExecutionContext,
session_id: str,
bot_id: str,
bot_name: str,
@@ -392,7 +445,9 @@ class MonitoringService:
user_name: str | None = None,
) -> None:
"""Record a new session"""
workspace_uuid = self._require_write_context(context)
session_data = {
'workspace_uuid': workspace_uuid,
'session_id': session_id,
'bot_id': bot_id,
'bot_name': bot_name,
@@ -413,6 +468,7 @@ class MonitoringService:
async def update_session_activity(
self,
context: ExecutionContext,
session_id: str,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
@@ -424,6 +480,7 @@ class MonitoringService:
Returns:
True if session was found and updated, False if session doesn't exist.
"""
workspace_uuid = self._require_write_context(context)
update_values = {
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
@@ -437,7 +494,10 @@ class MonitoringService:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_monitoring.MonitoringSession)
.where(persistence_monitoring.MonitoringSession.session_id == session_id)
.where(
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringSession.session_id == session_id,
)
.values(update_values)
)
# Check if any rows were updated
@@ -445,6 +505,7 @@ class MonitoringService:
async def record_error(
self,
context: ExecutionContext,
bot_id: str,
bot_name: str,
pipeline_id: str,
@@ -456,9 +517,11 @@ class MonitoringService:
message_id: str | None = None,
) -> str:
"""Record an error"""
workspace_uuid = self._require_write_context(context)
error_id = str(uuid.uuid4())
error_data = {
'id': error_id,
'workspace_uuid': workspace_uuid,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'error_type': error_type,
'error_message': error_message,
@@ -479,12 +542,14 @@ class MonitoringService:
async def update_message_status(
self,
context: ExecutionContext,
message_id: str,
status: str,
level: str | None = None,
variables: str | None = None,
) -> None:
"""Update message status and optionally variables"""
workspace_uuid = self._require_write_context(context)
update_values = {'status': status}
if level is not None:
update_values['level'] = level
@@ -493,7 +558,10 @@ class MonitoringService:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.id == message_id)
.where(
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringMessage.id == message_id,
)
.values(update_values)
)
@@ -501,17 +569,19 @@ class MonitoringService:
async def get_overview_metrics(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Get overview metrics"""
workspace_uuid = require_workspace_uuid(context)
# Build base query conditions
message_conditions = []
llm_conditions = []
embedding_conditions = []
session_conditions = []
message_conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
llm_conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
embedding_conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
session_conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
if bot_ids:
message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
@@ -594,6 +664,7 @@ class MonitoringService:
async def get_token_statistics(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -612,8 +683,9 @@ class MonitoringService:
token accounting.
"""
LLMCall = persistence_monitoring.MonitoringLLMCall
workspace_uuid = require_workspace_uuid(context)
conditions = []
conditions = [LLMCall.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(LLMCall.bot_id.in_(bot_ids))
if pipeline_ids:
@@ -767,6 +839,7 @@ class MonitoringService:
async def get_messages(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
session_ids: list[str] | None = None,
@@ -776,7 +849,8 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get messages with filters"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
@@ -820,6 +894,7 @@ class MonitoringService:
async def get_llm_calls(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -828,7 +903,8 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get LLM calls with filters"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
@@ -871,6 +947,7 @@ class MonitoringService:
async def get_tool_calls(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
session_ids: list[str] | None = None,
@@ -880,7 +957,8 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get tool calls with filters"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids))
@@ -923,6 +1001,7 @@ class MonitoringService:
async def get_embedding_calls(
self,
context: TenantContext,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
knowledge_base_id: str | None = None,
@@ -930,7 +1009,8 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get embedding calls with filters"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
if start_time:
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
@@ -971,6 +1051,7 @@ class MonitoringService:
async def get_sessions(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -980,7 +1061,8 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get sessions with filters"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
@@ -1025,6 +1107,7 @@ class MonitoringService:
async def get_errors(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1033,7 +1116,8 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get errors with filters"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
@@ -1076,12 +1160,15 @@ class MonitoringService:
async def get_session_analysis(
self,
context: TenantContext,
session_id: str,
) -> dict:
"""Get detailed analysis for a specific session"""
workspace_uuid = require_workspace_uuid(context)
# Get session info
session_query = sqlalchemy.select(persistence_monitoring.MonitoringSession).where(
persistence_monitoring.MonitoringSession.session_id == session_id
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringSession.session_id == session_id,
)
session_result = await self.ap.persistence_mgr.execute_async(session_query)
session_row = session_result.first()
@@ -1097,7 +1184,10 @@ class MonitoringService:
# Get messages for this session
messages_query = (
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.session_id == session_id)
.where(
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringMessage.session_id == session_id,
)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.asc())
)
messages_result = await self.ap.persistence_mgr.execute_async(messages_query)
@@ -1118,7 +1208,8 @@ class MonitoringService:
# Get LLM calls for this session
llm_query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).where(
persistence_monitoring.MonitoringLLMCall.session_id == session_id
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringLLMCall.session_id == session_id,
)
llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
llm_rows = llm_result.all()
@@ -1146,7 +1237,10 @@ class MonitoringService:
# Get tool calls for this session
tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
.where(persistence_monitoring.MonitoringToolCall.session_id == session_id)
.where(
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
)
tool_result = await self.ap.persistence_mgr.execute_async(tool_query)
@@ -1174,7 +1268,10 @@ class MonitoringService:
# Get errors for this session
error_query = (
sqlalchemy.select(persistence_monitoring.MonitoringError)
.where(persistence_monitoring.MonitoringError.session_id == session_id)
.where(
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringError.session_id == session_id,
)
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
)
error_result = await self.ap.persistence_mgr.execute_async(error_query)
@@ -1228,12 +1325,15 @@ class MonitoringService:
async def get_message_details(
self,
context: TenantContext,
message_id: str,
) -> dict:
"""Get detailed information for a specific message including associated LLM calls and errors"""
workspace_uuid = require_workspace_uuid(context)
# Get message info
message_query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
persistence_monitoring.MonitoringMessage.id == message_id
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringMessage.id == message_id,
)
message_result = await self.ap.persistence_mgr.execute_async(message_query)
message_row = message_result.first()
@@ -1249,7 +1349,10 @@ class MonitoringService:
# Get LLM calls for this message
llm_query = (
sqlalchemy.select(persistence_monitoring.MonitoringLLMCall)
.where(persistence_monitoring.MonitoringLLMCall.message_id == message_id)
.where(
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringLLMCall.message_id == message_id,
)
.order_by(persistence_monitoring.MonitoringLLMCall.timestamp.asc())
)
llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
@@ -1271,7 +1374,10 @@ class MonitoringService:
# Get errors for this message
error_query = (
sqlalchemy.select(persistence_monitoring.MonitoringError)
.where(persistence_monitoring.MonitoringError.message_id == message_id)
.where(
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringError.message_id == message_id,
)
.order_by(persistence_monitoring.MonitoringError.timestamp.asc())
)
error_result = await self.ap.persistence_mgr.execute_async(error_query)
@@ -1379,6 +1485,7 @@ class MonitoringService:
async def export_messages(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1386,7 +1493,8 @@ class MonitoringService:
limit: int = 100000,
) -> list[dict]:
"""Export messages as list of dictionaries for CSV conversion"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
@@ -1432,6 +1540,7 @@ class MonitoringService:
async def export_llm_calls(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1439,7 +1548,8 @@ class MonitoringService:
limit: int = 100000,
) -> list[dict]:
"""Export LLM calls as list of dictionaries for CSV conversion"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
@@ -1485,13 +1595,15 @@ class MonitoringService:
async def export_embedding_calls(
self,
context: TenantContext,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
knowledge_base_id: str | None = None,
limit: int = 100000,
) -> list[dict]:
"""Export embedding calls as list of dictionaries for CSV conversion"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
if start_time:
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
@@ -1533,6 +1645,7 @@ class MonitoringService:
async def export_errors(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1540,7 +1653,8 @@ class MonitoringService:
limit: int = 100000,
) -> list[dict]:
"""Export errors as list of dictionaries for CSV conversion"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
@@ -1581,6 +1695,7 @@ class MonitoringService:
async def export_sessions(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1588,7 +1703,8 @@ class MonitoringService:
limit: int = 100000,
) -> list[dict]:
"""Export sessions as list of dictionaries for CSV conversion"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
@@ -1633,6 +1749,7 @@ class MonitoringService:
async def record_feedback(
self,
context: ExecutionContext,
feedback_id: str,
feedback_type: int,
feedback_content: str | None = None,
@@ -1646,7 +1763,7 @@ class MonitoringService:
stream_id: str | None = None,
user_id: str | None = None,
platform: str | None = None,
) -> str:
) -> str | None:
"""Record user feedback (like/dislike) from AI Bot conversation.
Args:
@@ -1669,6 +1786,7 @@ class MonitoringService:
"""
import json
workspace_uuid = self._require_write_context(context)
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
@@ -1677,13 +1795,19 @@ class MonitoringService:
# Handle cancel feedback (type=3): delete existing record
if feedback_type == 3:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
sqlalchemy.delete(MonitoringFeedback).where(
MonitoringFeedback.workspace_uuid == workspace_uuid,
MonitoringFeedback.feedback_id == feedback_id,
)
)
return None
# Check if record with this feedback_id already exists
existing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
sqlalchemy.select(MonitoringFeedback).where(
MonitoringFeedback.workspace_uuid == workspace_uuid,
MonitoringFeedback.feedback_id == feedback_id,
)
)
existing_row = existing_result.first()
@@ -1692,7 +1816,10 @@ class MonitoringService:
existing = existing_row[0] if isinstance(existing_row, tuple) else existing_row
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(MonitoringFeedback)
.where(MonitoringFeedback.feedback_id == feedback_id)
.where(
MonitoringFeedback.workspace_uuid == workspace_uuid,
MonitoringFeedback.feedback_id == feedback_id,
)
.values(
timestamp=now,
feedback_type=feedback_type,
@@ -1715,6 +1842,7 @@ class MonitoringService:
record_id = str(uuid.uuid4())
record_data = {
'id': record_id,
'workspace_uuid': workspace_uuid,
'timestamp': now,
'feedback_id': feedback_id,
'feedback_type': feedback_type,
@@ -1737,7 +1865,10 @@ class MonitoringService:
# UNIQUE constraint conflict (concurrent feedback for same feedback_id)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(MonitoringFeedback)
.where(MonitoringFeedback.feedback_id == feedback_id)
.where(
MonitoringFeedback.workspace_uuid == workspace_uuid,
MonitoringFeedback.feedback_id == feedback_id,
)
.values(
timestamp=now,
feedback_type=feedback_type,
@@ -1749,6 +1880,7 @@ class MonitoringService:
async def get_feedback_stats(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1759,7 +1891,8 @@ class MonitoringService:
Returns:
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
"""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
@@ -1837,6 +1970,7 @@ class MonitoringService:
async def get_feedback_list(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
feedback_type: int | None = None,
@@ -1846,7 +1980,8 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get feedback list with filters."""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
@@ -1889,6 +2024,7 @@ class MonitoringService:
async def export_feedback(
self,
context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1896,7 +2032,8 @@ class MonitoringService:
limit: int = 100000,
) -> list[dict]:
"""Export feedback as list of dictionaries for CSV conversion."""
conditions = []
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
+129 -46
View File
@@ -6,6 +6,9 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import pipeline as persistence_pipeline
from ....workspace.errors import WorkspaceNotFoundError
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
default_stage_order = [
@@ -30,7 +33,8 @@ class PipelineService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_pipeline_metadata(self) -> list[dict]:
async def get_pipeline_metadata(self, context: TenantContext) -> list[dict]:
require_workspace_uuid(context)
return [
self.ap.pipeline_config_meta_trigger,
self.ap.pipeline_config_meta_safety,
@@ -38,8 +42,19 @@ class PipelineService:
self.ap.pipeline_config_meta_output,
]
async def get_pipelines(self, sort_by: str = 'created_at', sort_order: str = 'DESC') -> list[dict]:
query = sqlalchemy.select(persistence_pipeline.LegacyPipeline)
async def get_pipelines(
self,
context: TenantContext,
sort_by: str = 'created_at',
sort_order: str = 'DESC',
*,
include_secret: bool = False,
) -> list[dict]:
query = scope_statement(
sqlalchemy.select(persistence_pipeline.LegacyPipeline),
persistence_pipeline.LegacyPipeline,
context,
)
if sort_by == 'created_at':
if sort_order == 'DESC':
@@ -54,15 +69,26 @@ class PipelineService:
result = await self.ap.persistence_mgr.execute_async(query)
pipelines = result.all()
return [
serialized = [
self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline in pipelines
]
return serialized if include_secret else [redact_secrets(pipeline) for pipeline in serialized]
async def get_pipeline(self, pipeline_uuid: str) -> dict | None:
async def get_pipeline(
self,
context: TenantContext,
pipeline_uuid: str,
*,
include_secret: bool = False,
) -> dict | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
scope_statement(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
),
persistence_pipeline.LegacyPipeline,
context,
)
)
@@ -71,20 +97,24 @@ class PipelineService:
if pipeline is None:
return None
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
serialized = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
return serialized if include_secret else redact_secrets(serialized)
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
async def create_pipeline(self, context: TenantContext, pipeline_data: dict, default: bool = False) -> str:
from ....utils import paths as path_utils
workspace_uuid = require_workspace_uuid(context)
# Check limitation
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_pipelines = limitation.get('max_pipelines', -1)
if max_pipelines >= 0:
existing_pipelines = await self.get_pipelines()
existing_pipelines = await self.get_pipelines(context)
if len(existing_pipelines) >= max_pipelines:
raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached')
pipeline_data = pipeline_data.copy()
pipeline_data['uuid'] = str(uuid.uuid4())
pipeline_data['workspace_uuid'] = workspace_uuid
pipeline_data['for_version'] = self.ap.ver_mgr.get_current_version()
pipeline_data['stages'] = default_stage_order.copy()
pipeline_data['is_default'] = default
@@ -108,79 +138,122 @@ class PipelineService:
sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**pipeline_data)
)
pipeline = await self.get_pipeline(pipeline_data['uuid'])
pipeline = await self.get_pipeline(context, pipeline_data['uuid'], include_secret=True)
await self.ap.pipeline_mgr.load_pipeline(pipeline)
await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
return pipeline_data['uuid']
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
async def update_pipeline(self, context: TenantContext, pipeline_uuid: str, pipeline_data: dict) -> None:
workspace_uuid = require_workspace_uuid(context)
pipeline_data = pipeline_data.copy()
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
for protected_field in ('uuid', 'workspace_uuid', 'for_version', 'stages', 'is_default'):
pipeline_data.pop(protected_field, None)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
.values(**pipeline_data)
)
if 'config' in pipeline_data:
current_config = None
if contains_secret_placeholder(pipeline_data['config']):
current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
if current_pipeline is None:
raise WorkspaceNotFoundError('Pipeline not found')
current_config = current_pipeline.get('config', {})
pipeline_data['config'] = restore_secret_placeholders(
pipeline_data['config'],
current_config if current_config is not None else {},
)
pipeline = await self.get_pipeline(pipeline_uuid)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
.values(**pipeline_data),
persistence_pipeline.LegacyPipeline,
workspace_uuid,
)
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Pipeline not found')
pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
if pipeline is None:
raise WorkspaceNotFoundError('Pipeline not found')
if 'name' in pipeline_data:
from ....entity.persistence import bot as persistence_bot
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid)
scope_statement(
sqlalchemy.select(persistence_bot.Bot).where(
persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid
),
persistence_bot.Bot,
workspace_uuid,
)
)
bots = result.all()
for bot in bots:
bot_data = {'use_pipeline_name': pipeline_data['name']}
await self.ap.bot_service.update_bot(bot.uuid, bot_data)
await self.ap.bot_service.update_bot(context, bot.uuid, bot_data)
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
await self.ap.pipeline_mgr.load_pipeline(pipeline)
await self.ap.pipeline_mgr.remove_pipeline(context, pipeline_uuid)
await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
# update all conversation that use this pipeline
for session in self.ap.sess_mgr.session_list:
if session.using_conversation is not None and session.using_conversation.pipeline_uuid == pipeline_uuid:
if (
session.using_conversation is not None
and session.using_conversation.pipeline_uuid == pipeline_uuid
and getattr(session, 'workspace_uuid', workspace_uuid) == workspace_uuid
):
session.using_conversation = None
async def delete_pipeline(self, pipeline_uuid: str) -> None:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
async def delete_pipeline(self, context: TenantContext, pipeline_uuid: str) -> None:
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
),
persistence_pipeline.LegacyPipeline,
context,
)
)
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Pipeline not found')
await self.ap.pipeline_mgr.remove_pipeline(context, pipeline_uuid)
async def copy_pipeline(self, pipeline_uuid: str) -> str:
async def copy_pipeline(self, context: TenantContext, pipeline_uuid: str) -> str:
"""Copy a pipeline with all its configurations"""
workspace_uuid = require_workspace_uuid(context)
# Check limitation
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_pipelines = limitation.get('max_pipelines', -1)
if max_pipelines >= 0:
existing_pipelines = await self.get_pipelines()
existing_pipelines = await self.get_pipelines(context)
if len(existing_pipelines) >= max_pipelines:
raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached')
# Get the original pipeline
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
scope_statement(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
),
persistence_pipeline.LegacyPipeline,
workspace_uuid,
)
)
original_pipeline = result.first()
if original_pipeline is None:
raise ValueError(f'Pipeline {pipeline_uuid} not found')
raise WorkspaceNotFoundError(f'Pipeline {pipeline_uuid} not found')
# Create new pipeline data
new_uuid = str(uuid.uuid4())
new_pipeline_data = {
'uuid': new_uuid,
'workspace_uuid': workspace_uuid,
'name': f'{original_pipeline.name} (Copy)',
'description': original_pipeline.description,
'for_version': self.ap.ver_mgr.get_current_version(),
@@ -207,13 +280,14 @@ class PipelineService:
)
# Load the new pipeline
pipeline = await self.get_pipeline(new_uuid)
await self.ap.pipeline_mgr.load_pipeline(pipeline)
pipeline = await self.get_pipeline(context, new_uuid, include_secret=True)
await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
return new_uuid
async def update_pipeline_extensions(
self,
context: TenantContext,
pipeline_uuid: str,
bound_plugins: list[dict],
bound_mcp_servers: list[str] = None,
@@ -225,16 +299,21 @@ class PipelineService:
mcp_resource_agent_read_enabled: bool | None = None,
) -> None:
"""Update the bound plugins and MCP servers for a pipeline"""
workspace_uuid = require_workspace_uuid(context)
# Get current pipeline
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
scope_statement(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
),
persistence_pipeline.LegacyPipeline,
workspace_uuid,
)
)
pipeline = result.first()
if pipeline is None:
raise ValueError(f'Pipeline {pipeline_uuid} not found')
raise WorkspaceNotFoundError(f'Pipeline {pipeline_uuid} not found')
# Update extensions_preferences
extensions_preferences = pipeline.extensions_preferences or {}
@@ -252,12 +331,16 @@ class PipelineService:
extensions_preferences['mcp_resources'] = bound_mcp_resources
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
.values(extensions_preferences=extensions_preferences)
scope_statement(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
.values(extensions_preferences=extensions_preferences),
persistence_pipeline.LegacyPipeline,
workspace_uuid,
)
)
# Reload pipeline to apply changes
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
pipeline = await self.get_pipeline(pipeline_uuid)
await self.ap.pipeline_mgr.load_pipeline(pipeline)
await self.ap.pipeline_mgr.remove_pipeline(context, pipeline_uuid)
pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
+156 -57
View File
@@ -7,6 +7,9 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import model as persistence_model
from ....workspace.errors import WorkspaceNotFoundError
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
class ModelProviderService:
@@ -35,9 +38,15 @@ class ModelProviderService:
return normalized_keys
async def get_providers(self) -> list[dict]:
async def get_providers(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
"""Get all providers"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider),
persistence_model.ModelProvider,
context,
)
)
providers = result.all()
providers_list = []
for p in providers:
@@ -50,14 +59,25 @@ class ModelProviderService:
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
except Exception:
provider_dict['api_keys'] = []
if not include_secret:
provider_dict = redact_secrets(provider_dict)
providers_list.append(provider_dict)
return providers_list
async def get_provider(self, provider_uuid: str) -> dict | None:
async def get_provider(
self,
context: TenantContext,
provider_uuid: str,
include_secret: bool = False,
) -> dict | None:
"""Get a single provider by UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
),
persistence_model.ModelProvider,
context,
)
)
provider = result.first()
@@ -72,103 +92,171 @@ class ModelProviderService:
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
except Exception:
provider_dict['api_keys'] = []
if not include_secret:
provider_dict = redact_secrets(provider_dict)
return provider_dict
async def create_provider(self, provider_data: dict) -> str:
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
"""Create a new provider"""
provider_data = provider_data.copy()
provider_data['uuid'] = str(uuid.uuid4())
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
provider_data['workspace_uuid'] = require_workspace_uuid(context)
provider_data['api_keys'] = self._normalize_api_keys(
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
# load to runtime
runtime_provider = await self.ap.model_mgr.load_provider(provider_data)
self.ap.model_mgr.provider_dict[runtime_provider.provider_entity.uuid] = runtime_provider
runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data)
await self.ap.model_mgr.cache_provider(context, runtime_provider)
return provider_data['uuid']
async def update_provider(self, provider_uuid: str, provider_data: dict) -> None:
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
"""Update an existing provider"""
if 'uuid' in provider_data:
del provider_data['uuid']
provider_data = provider_data.copy()
provider_data.pop('uuid', None)
provider_data.pop('workspace_uuid', None)
if 'api_keys' in provider_data:
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == provider_uuid)
.values(**provider_data)
submitted_keys = provider_data.get('api_keys')
if contains_secret_placeholder(submitted_keys, sensitive=True):
current_provider = await self.get_provider(context, provider_uuid, include_secret=True)
if current_provider is None:
raise WorkspaceNotFoundError('Provider not found')
submitted_keys = restore_secret_placeholders(
submitted_keys,
current_provider.get('api_keys', []),
sensitive=True,
)
provider_data['api_keys'] = self._normalize_api_keys(submitted_keys)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == provider_uuid)
.values(**provider_data),
persistence_model.ModelProvider,
context,
)
)
await self.ap.model_mgr.reload_provider(provider_uuid)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Provider not found')
await self.ap.model_mgr.reload_provider(context, provider_uuid)
async def delete_provider(self, provider_uuid: str) -> None:
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
"""Delete a provider (only if no models reference it)"""
workspace_uuid = require_workspace_uuid(context)
# Check if any models use this provider
llm_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
scope_statement(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
),
persistence_model.LLMModel,
workspace_uuid,
)
)
if llm_result.first() is not None:
raise ValueError('Cannot delete provider: LLM models still reference it')
embedding_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
scope_statement(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
),
persistence_model.EmbeddingModel,
workspace_uuid,
)
)
if embedding_result.first() is not None:
raise ValueError('Cannot delete provider: Embedding models still reference it')
rerank_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
scope_statement(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
),
persistence_model.RerankModel,
workspace_uuid,
)
)
if rerank_result.first() is not None:
raise ValueError('Cannot delete provider: Rerank models still reference it')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
),
persistence_model.ModelProvider,
workspace_uuid,
)
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Provider not found')
await self.ap.model_mgr.remove_provider(provider_uuid)
await self.ap.model_mgr.remove_provider(context, provider_uuid)
async def get_provider_model_counts(self, provider_uuid: str) -> dict:
async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
"""Get count of models using this provider"""
workspace_uuid = require_workspace_uuid(context)
if await self.get_provider(context, provider_uuid) is None:
raise WorkspaceNotFoundError('Provider not found')
llm_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.LLMModel)
.where(persistence_model.LLMModel.provider_uuid == provider_uuid)
scope_statement(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.LLMModel)
.where(persistence_model.LLMModel.provider_uuid == provider_uuid),
persistence_model.LLMModel,
workspace_uuid,
)
)
llm_count = llm_result.scalar() or 0
embedding_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.EmbeddingModel)
.where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid)
scope_statement(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.EmbeddingModel)
.where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid),
persistence_model.EmbeddingModel,
workspace_uuid,
)
)
embedding_count = embedding_result.scalar() or 0
rerank_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.RerankModel)
.where(persistence_model.RerankModel.provider_uuid == provider_uuid)
scope_statement(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.RerankModel)
.where(persistence_model.RerankModel.provider_uuid == provider_uuid),
persistence_model.RerankModel,
workspace_uuid,
)
)
rerank_count = rerank_result.scalar() or 0
return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count}
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
async def find_or_create_provider(
self,
context: TenantContext,
requester: str,
base_url: str,
api_keys: list,
) -> str:
"""Find existing provider or create new one"""
api_keys = self._normalize_api_keys(api_keys)
workspace_uuid = require_workspace_uuid(context)
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
# Try to find existing provider with same config
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.requester == requester,
persistence_model.ModelProvider.base_url == base_url,
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.requester == requester,
persistence_model.ModelProvider.base_url == base_url,
),
persistence_model.ModelProvider,
workspace_uuid,
)
)
for provider in result.all():
@@ -187,29 +275,38 @@ class ModelProviderService:
pass
return await self.create_provider(
context,
{
'name': provider_name,
'requester': requester,
'base_url': base_url,
'api_keys': api_keys,
}
},
)
async def update_space_model_provider_api_keys(self, api_key: str) -> None:
async def update_space_model_provider_api_keys(self, context: TenantContext, api_key: str) -> None:
"""Update Space model provider API keys"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
.values(api_keys=self._normalize_api_keys(api_key))
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
.values(api_keys=self._normalize_api_keys(api_key)),
persistence_model.ModelProvider,
context,
)
)
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Provider not found')
await self.ap.model_mgr.reload_provider(context, '00000000-0000-0000-0000-000000000000')
async def scan_provider_models(self, provider_uuid: str, model_type: str | None = None) -> dict:
provider = await self.get_provider(provider_uuid)
async def scan_provider_models(
self, context: TenantContext, provider_uuid: str, model_type: str | None = None
) -> dict:
provider = await self.get_provider(context, provider_uuid, include_secret=True)
if provider is None:
raise ValueError('provider not found')
raise WorkspaceNotFoundError('Provider not found')
runtime_provider = await self.ap.model_mgr.load_provider(provider)
runtime_provider = await self.ap.model_mgr.load_provider(context, provider)
try:
scan_result = await runtime_provider.requester.scan_models(
@@ -230,8 +327,10 @@ class ModelProviderService:
scanned_models = scan_result
debug_info = None
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid)
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(context, provider_uuid)
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
context, provider_uuid
)
existing_llm_names = {model['name'] for model in llm_models}
existing_embedding_names = {model['name'] for model in embedding_models}
+336
View File
@@ -0,0 +1,336 @@
from __future__ import annotations
import copy
import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
SECRET_MASK = '***'
_MISSING_SECRET = object()
_SENSITIVE_NAMES = frozenset(
{
'api_key',
'api_keys',
'apikey',
'apikeys',
'auth',
'authorization',
'cookie',
'credentials',
'database_url',
'dsn',
'header_value',
'key',
'proxy_authorization',
'set_cookie',
'webhook_url',
}
)
_SENSITIVE_TOKENS = frozenset(
{
'apikey',
'credential',
'credentials',
'passwd',
'password',
'secret',
'token',
}
)
_KEY_QUALIFIERS = frozenset(
{
'access',
'api',
'auth',
'bearer',
'client',
'debug',
'encryption',
'private',
'signing',
}
)
_SENSITIVE_URL_QUERY_NAMES = frozenset(
{
'code',
'credential',
'credentials',
'password',
'passwd',
'sig',
'signature',
}
)
def _normalize_key(key: object) -> str:
value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
def is_sensitive_key(key: object) -> bool:
"""Return whether a configuration key conventionally carries a secret."""
normalized = _normalize_key(key)
if normalized in _SENSITIVE_NAMES:
return True
tokens = frozenset(token for token in normalized.split('_') if token)
if tokens & _SENSITIVE_TOKENS:
return True
return bool(tokens & {'key', 'keys'}) and bool(tokens & _KEY_QUALIFIERS)
def is_url_key(key: object) -> bool:
"""Return whether a configuration field conventionally carries a URL."""
normalized = _normalize_key(key)
return normalized == 'url' or normalized.endswith('_url')
def _is_sensitive_url_query_key(key: object) -> bool:
normalized = _normalize_key(key)
return (
is_sensitive_key(key) or normalized in _SENSITIVE_URL_QUERY_NAMES or normalized.endswith(('_sig', '_signature'))
)
def _redact_url_string(value: str) -> str:
if not value:
return value
try:
parsed = urlsplit(value)
netloc = parsed.netloc
if '@' in netloc:
_, host = netloc.rsplit('@', 1)
netloc = f'{SECRET_MASK}@{host}'
query = urlencode(
[
(key, SECRET_MASK if _is_sensitive_url_query_key(key) and item else item)
for key, item in parse_qsl(parsed.query, keep_blank_values=True)
],
doseq=True,
safe='*',
)
return urlunsplit((parsed.scheme, netloc, parsed.path, query, parsed.fragment))
except (TypeError, ValueError):
# A malformed URL cannot be safely decomposed, so fail closed.
return SECRET_MASK
def redact_url_secrets(value):
"""Redact URL userinfo and credential-like query values."""
if isinstance(value, str):
return _redact_url_string(value)
if isinstance(value, list):
return [redact_url_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_url_secrets(item) for item in value)
return copy.deepcopy(value)
def _contains_url_secret_placeholder(value) -> bool:
if isinstance(value, str):
if value == SECRET_MASK:
return True
try:
parsed = urlsplit(value)
if '@' in parsed.netloc and SECRET_MASK in parsed.netloc.rsplit('@', 1)[0]:
return True
return any(
item == SECRET_MASK and _is_sensitive_url_query_key(key)
for key, item in parse_qsl(parsed.query, keep_blank_values=True)
)
except (TypeError, ValueError):
return False
if isinstance(value, (list, tuple)):
return any(_contains_url_secret_placeholder(item) for item in value)
return False
def _restore_url_string(value: str, current_value) -> str:
if value == SECRET_MASK:
if current_value is _MISSING_SECRET:
raise ValueError('Masked URL secret has no existing value')
return copy.deepcopy(current_value)
try:
submitted = urlsplit(value)
except (TypeError, ValueError):
return value
current = None
if isinstance(current_value, str):
try:
current = urlsplit(current_value)
except (TypeError, ValueError):
current = None
netloc = submitted.netloc
if '@' in netloc:
submitted_userinfo, host = netloc.rsplit('@', 1)
if SECRET_MASK in submitted_userinfo:
if current is None or '@' not in current.netloc:
raise ValueError('Masked URL userinfo has no existing value')
current_userinfo, _ = current.netloc.rsplit('@', 1)
netloc = f'{current_userinfo}@{host}'
current_query: dict[str, list[str]] = {}
if current is not None:
for key, item in parse_qsl(current.query, keep_blank_values=True):
current_query.setdefault(_normalize_key(key), []).append(item)
consumed: dict[str, int] = {}
restored_query: list[tuple[str, str]] = []
for key, item in parse_qsl(submitted.query, keep_blank_values=True):
normalized = _normalize_key(key)
if item == SECRET_MASK and _is_sensitive_url_query_key(key):
index = consumed.get(normalized, 0)
candidates = current_query.get(normalized, [])
if index >= len(candidates):
raise ValueError('Masked URL query secret has no existing value')
item = candidates[index]
consumed[normalized] = index + 1
restored_query.append((key, item))
return urlunsplit(
(
submitted.scheme,
netloc,
submitted.path,
urlencode(restored_query, doseq=True, safe='*'),
submitted.fragment,
)
)
def restore_url_secret_placeholders(value, current_value=_MISSING_SECRET):
"""Restore URL placeholders from the corresponding persisted URL."""
if isinstance(value, str):
return _restore_url_string(value, current_value)
if isinstance(value, list):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return [
restore_url_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
)
for index, item in enumerate(value)
]
if isinstance(value, tuple):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return tuple(
restore_url_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
)
for index, item in enumerate(value)
)
return copy.deepcopy(value)
def mask_secret_value(value):
"""Return a shape-preserving copy whose non-empty leaves are masked."""
if isinstance(value, dict):
return {key: mask_secret_value(item) for key, item in value.items()}
if isinstance(value, list):
return [mask_secret_value(item) for item in value]
if isinstance(value, tuple):
return tuple(mask_secret_value(item) for item in value)
if value is None or value == '':
return value
return SECRET_MASK
def redact_secrets(value):
"""Return a recursively redacted copy without mutating the source value."""
if isinstance(value, dict):
return {
key: (
mask_secret_value(item)
if is_sensitive_key(key)
else redact_url_secrets(item)
if is_url_key(key)
else redact_secrets(item)
)
for key, item in value.items()
}
if isinstance(value, list):
return [redact_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_secrets(item) for item in value)
return copy.deepcopy(value)
def restore_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
"""Restore masked leaves from existing data before a management write.
``***`` is a reserved placeholder only inside a sensitive field. A masked
leaf without an existing counterpart is rejected so it can never become a
persisted credential. Empty values and explicit replacements pass through.
"""
if sensitive and value == SECRET_MASK:
if current_value is _MISSING_SECRET:
raise ValueError('Masked secret has no existing value')
return copy.deepcopy(current_value)
if isinstance(value, dict):
current_mapping = current_value if isinstance(current_value, dict) else {}
return {
key: (
restore_url_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
)
if not sensitive and not is_sensitive_key(key) and is_url_key(key)
else restore_secret_placeholders(
item,
current_mapping.get(key, _MISSING_SECRET),
sensitive=sensitive or is_sensitive_key(key),
)
)
for key, item in value.items()
}
if isinstance(value, list):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return [
restore_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
]
if isinstance(value, tuple):
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
return tuple(
restore_secret_placeholders(
item,
current_items[index] if index < len(current_items) else _MISSING_SECRET,
sensitive=sensitive,
)
for index, item in enumerate(value)
)
return copy.deepcopy(value)
def contains_secret_placeholder(value, *, sensitive: bool = False) -> bool:
"""Return whether ``value`` contains a meaningful masked secret leaf."""
if sensitive and value == SECRET_MASK:
return True
if isinstance(value, dict):
return any(
(
_contains_url_secret_placeholder(item)
if not sensitive and not is_sensitive_key(key) and is_url_key(key)
else contains_secret_placeholder(item, sensitive=sensitive or is_sensitive_key(key))
)
for key, item in value.items()
)
if isinstance(value, (list, tuple)):
return any(contains_secret_placeholder(item, sensitive=sensitive) for item in value)
return False
+115 -40
View File
@@ -12,6 +12,8 @@ import httpx
from ....core import app
from ....skill.utils import parse_frontmatter
from ..context import ExecutionContext
from .tenant import TenantContext, require_workspace_uuid
_PUBLIC_SKILL_FIELDS = (
@@ -75,75 +77,112 @@ class SkillService:
"""Backwards-compatible alias preserved for clarity at call sites."""
self._require_box(action)
async def _execution_context(self, context: TenantContext) -> ExecutionContext:
workspace_uuid = require_workspace_uuid(context)
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
generation = getattr(context, 'placement_generation', None)
if not instance_uuid or isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
raise ValueError('Skill operations require an explicit fenced execution context')
binding = await self.ap.workspace_service.get_execution_binding(
workspace_uuid,
expected_generation=generation,
)
if binding.instance_uuid != instance_uuid:
raise ValueError('Skill execution context belongs to another LangBot instance')
return ExecutionContext(
instance_uuid=instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=generation,
bot_uuid=getattr(context, 'bot_uuid', None),
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
query_uuid=getattr(context, 'query_uuid', None),
)
@staticmethod
def _serialize_skill(skill: dict) -> dict:
return {field: skill.get(field) for field in _PUBLIC_SKILL_FIELDS if field in skill}
async def list_skills(self) -> list[dict]:
async def list_skills(self, context: TenantContext) -> list[dict]:
execution_context = await self._execution_context(context)
# When Box is unavailable, surface an empty list rather than raising —
# the skills page should render cleanly, and the UI separately renders
# a "Box disabled / unavailable" banner via useBoxStatus.
box_service = self._box_service()
if box_service is None:
return []
return [self._serialize_skill(skill) for skill in await box_service.list_skills()]
return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)]
async def get_skill(self, skill_name: str) -> Optional[dict]:
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
execution_context = await self._execution_context(context)
box_service = self._box_service()
if box_service is None:
return None
skill = await box_service.get_skill(skill_name)
skill = await box_service.get_skill(execution_context, skill_name)
return self._serialize_skill(skill) if skill else None
async def get_skill_by_name(self, name: str) -> Optional[dict]:
return await self.get_skill(name)
async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]:
return await self.get_skill(context, name)
async def create_skill(self, data: dict) -> dict:
async def create_skill(self, context: TenantContext, data: dict) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Creating a skill')
created = await box_service.create_skill(data)
await self._reload_skills()
created = await box_service.create_skill(execution_context, data)
await self._reload_skills(execution_context)
return self._serialize_skill(created)
async def update_skill(self, skill_name: str, data: dict) -> dict:
async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Editing a skill')
updated = await box_service.update_skill(skill_name, data)
await self._reload_skills()
updated = await box_service.update_skill(execution_context, skill_name, data)
await self._reload_skills(execution_context)
return self._serialize_skill(updated)
async def delete_skill(self, skill_name: str) -> bool:
async def delete_skill(self, context: TenantContext, skill_name: str) -> bool:
execution_context = await self._execution_context(context)
box_service = self._require_box('Deleting a skill')
await box_service.delete_skill(skill_name)
await self._reload_skills()
await box_service.delete_skill(execution_context, skill_name)
await self._reload_skills(execution_context)
return True
async def list_skill_files(
self,
context: TenantContext,
skill_name: str,
path: str = '.',
include_hidden: bool = False,
max_entries: int = 200,
) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Browsing skill files')
return await box_service.list_skill_files(skill_name, path, include_hidden, max_entries)
return await box_service.list_skill_files(execution_context, skill_name, path, include_hidden, max_entries)
async def read_skill_file(self, skill_name: str, path: str) -> dict:
async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Reading a skill file')
return await box_service.read_skill_file(skill_name, path)
return await box_service.read_skill_file(execution_context, skill_name, path)
async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict:
async def write_skill_file(self, context: TenantContext, skill_name: str, path: str, content: str) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Editing skill files')
result = await box_service.write_skill_file(skill_name, path, content)
await self._reload_skills()
result = await box_service.write_skill_file(execution_context, skill_name, path, content)
await self._reload_skills(execution_context)
return result
async def install_from_github(self, data: dict) -> list[dict]:
async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
execution_context = await self._execution_context(context)
box_service = self._require_box('Installing a skill from GitHub')
owner = str(data['owner']).strip()
repo = str(data['repo']).strip()
release_tag = str(data.get('release_tag', '')).strip()
raw_asset_url = str(data['asset_url']).strip()
if self._is_github_skill_md_url(raw_asset_url):
return await self._install_github_skill_md(raw_asset_url, owner=owner, repo=repo, data=data)
return await self._install_github_skill_md(
execution_context,
raw_asset_url,
owner=owner,
repo=repo,
data=data,
)
asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag)
source_subdir = str(data.get('source_subdir', '') or '').strip()
@@ -151,29 +190,37 @@ class SkillService:
zip_bytes = await self._download_github_asset(asset_url)
filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip'
installed = await box_service.install_skill_zip(
execution_context,
zip_bytes,
filename,
source_paths=data.get('source_paths') or [],
source_path=str(data.get('source_path', '') or ''),
source_subdir=source_subdir,
)
await self._reload_skills()
await self._reload_skills(execution_context)
return [self._serialize_skill(skill) for skill in installed]
async def preview_install_from_github(self, data: dict) -> list[dict]:
async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
execution_context = await self._execution_context(context)
box_service = self._require_box('Previewing a skill from GitHub')
owner = str(data['owner']).strip()
repo = str(data['repo']).strip()
release_tag = str(data.get('release_tag', '')).strip()
raw_asset_url = str(data['asset_url']).strip()
if self._is_github_skill_md_url(raw_asset_url):
return await self._preview_github_skill_md(raw_asset_url, owner=owner, repo=repo)
return await self._preview_github_skill_md(
execution_context,
raw_asset_url,
owner=owner,
repo=repo,
)
asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag)
source_subdir = str(data.get('source_subdir', '') or '').strip()
zip_bytes = await self._download_github_asset(asset_url)
return await box_service.preview_skill_zip(
execution_context,
zip_bytes,
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
source_subdir=source_subdir,
@@ -181,27 +228,45 @@ class SkillService:
async def install_from_zip_upload(
self,
context: TenantContext,
*,
file_bytes: bytes,
filename: str,
source_paths: list[str] | None = None,
source_path: str = '',
) -> list[dict]:
execution_context = await self._execution_context(context)
box_service = self._require_box('Installing a skill from upload')
installed = await box_service.install_skill_zip(
execution_context,
file_bytes,
filename,
source_paths=source_paths or [],
source_path=source_path,
)
await self._reload_skills()
await self._reload_skills(execution_context)
return [self._serialize_skill(skill) for skill in installed]
async def preview_install_from_zip_upload(self, *, file_bytes: bytes, filename: str) -> list[dict]:
async def preview_install_from_zip_upload(
self,
context: TenantContext,
*,
file_bytes: bytes,
filename: str,
) -> list[dict]:
execution_context = await self._execution_context(context)
box_service = self._require_box('Previewing a skill upload')
return await box_service.preview_skill_zip(file_bytes, filename)
return await box_service.preview_skill_zip(execution_context, file_bytes, filename)
async def _install_github_skill_md(self, asset_url: str, *, owner: str, repo: str, data: dict) -> list[dict]:
async def _install_github_skill_md(
self,
context: TenantContext,
asset_url: str,
*,
owner: str,
repo: str,
data: dict,
) -> list[dict]:
box_service = self._require_box('Installing a skill from GitHub')
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
asset_url,
@@ -210,38 +275,48 @@ class SkillService:
)
installed = await box_service.install_skill_zip(
context,
zip_bytes,
filename,
source_paths=data.get('source_paths') or [],
source_path=str(data.get('source_path', '') or ''),
target_suffix='',
)
await self._reload_skills()
await self._reload_skills(context)
return [self._serialize_skill(skill) for skill in installed]
async def _preview_github_skill_md(self, asset_url: str, *, owner: str, repo: str) -> list[dict]:
async def _preview_github_skill_md(
self,
context: TenantContext,
asset_url: str,
*,
owner: str,
repo: str,
) -> list[dict]:
box_service = self._require_box('Previewing a skill from GitHub')
zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
asset_url,
owner=owner,
repo=repo,
)
return await box_service.preview_skill_zip(zip_bytes, f'{package_name}.zip', target_suffix='')
return await box_service.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
async def reload_skills(self) -> list[dict]:
await self._reload_skills()
return await self.list_skills()
async def reload_skills(self, context: TenantContext) -> list[dict]:
execution_context = await self._execution_context(context)
await self._reload_skills(execution_context)
return await self.list_skills(execution_context)
async def scan_directory_async(self, path: str) -> dict:
async def scan_directory_async(self, context: TenantContext, path: str) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Scanning a skill directory')
return await box_service.scan_skill_directory(path)
return await box_service.scan_skill_directory(execution_context, path)
async def _reload_skills(self) -> None:
async def _reload_skills(self, context: TenantContext) -> None:
skill_mgr = getattr(self.ap, 'skill_mgr', None)
reload_skills = getattr(skill_mgr, 'reload_skills', None)
if not callable(reload_skills):
return
result = reload_skills()
result = reload_skills(context)
if inspect.isawaitable(result):
await result
+5 -3
View File
@@ -85,12 +85,14 @@ class SpaceService:
def get_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str:
"""Get the Space OAuth authorization URL for redirect"""
from urllib.parse import urlencode
space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url']
params = f'redirect_uri={redirect_uri}'
params = {'redirect_uri': redirect_uri}
if state:
params += f'&state={state}'
return f'{authorize_url}?{params}'
params['state'] = state
return f'{authorize_url}?{urlencode(params)}'
async def exchange_oauth_code(self, code: str) -> typing.Dict:
"""Exchange OAuth authorization code for tokens"""
@@ -0,0 +1,34 @@
from __future__ import annotations
import typing
from ..authz import WorkspaceRequiredError
from ..context import ExecutionContext, RequestContext, WorkspaceContext
TenantContext: typing.TypeAlias = RequestContext | ExecutionContext | WorkspaceContext | str
def require_workspace_uuid(context: TenantContext | None) -> str:
"""Resolve an explicit Workspace UUID without allowing a global fallback."""
if isinstance(context, str):
workspace_uuid = context
elif isinstance(context, RequestContext):
workspace_uuid = context.workspace_uuid
elif isinstance(context, ExecutionContext):
workspace_uuid = context.workspace_uuid
elif isinstance(context, WorkspaceContext):
workspace_uuid = context.workspace_uuid
else:
raise WorkspaceRequiredError('Workspace context is required')
normalized = workspace_uuid.strip()
if not normalized:
raise WorkspaceRequiredError('Workspace context is required')
return normalized
def scope_statement(statement: typing.Any, model: typing.Any, context: TenantContext) -> typing.Any:
"""Add the mandatory Workspace predicate to a SQLAlchemy statement."""
return statement.where(model.workspace_uuid == require_workspace_uuid(context))
+374 -57
View File
@@ -6,51 +6,260 @@ import jwt
import datetime
import typing
import asyncio
import hashlib
import secrets
import time
import uuid
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....core import app
from ....entity.persistence import user
from ....utils import constants
from ....entity.errors import account as account_errors
from ....workspace.collaboration import normalize_email
from ..authz import Permission, permissions_for_role
if typing.TYPE_CHECKING:
from ....core.app import Application
class AccountExistsLoginRequiredError(ValueError):
code = 'account_exists_login_required'
class PublicRegistrationClosedError(ValueError):
code = 'registration_closed'
class ControlPlaneDirectoryRequiredError(PublicRegistrationClosedError):
code = 'control_plane_required'
class AccountDisabledError(ValueError):
code = 'account_disabled'
class UserService:
ap: app.Application
ap: Application
_create_user_lock: asyncio.Lock
def __init__(self, ap: app.Application) -> None:
def __init__(self, ap: Application) -> None:
self.ap = ap
self._create_user_lock = asyncio.Lock()
self._password_hash_lock = asyncio.Semaphore(1)
self._space_oauth_state_lock = asyncio.Lock()
self._space_oauth_states: dict[str, tuple[str, str | None, float]] = {}
@staticmethod
def _space_oauth_state_digest(state: str) -> str:
return hashlib.sha256(state.encode('utf-8')).hexdigest()
async def issue_space_oauth_state(
self,
purpose: typing.Literal['login', 'bind'],
*,
account_uuid: str | None = None,
ttl_seconds: int = 600,
) -> str:
"""Issue an opaque, single-use OAuth state without exposing a JWT."""
if purpose == 'bind' and not account_uuid:
raise ValueError('An Account is required for Space binding')
if purpose == 'login' and account_uuid is not None:
raise ValueError('Login state cannot be bound to an Account')
if ttl_seconds <= 0:
raise ValueError('OAuth state lifetime must be positive')
raw_state = secrets.token_urlsafe(32)
digest = self._space_oauth_state_digest(raw_state)
expires_at = time.monotonic() + min(ttl_seconds, 600)
async with self._space_oauth_state_lock:
now = time.monotonic()
self._space_oauth_states = {key: value for key, value in self._space_oauth_states.items() if value[2] > now}
if len(self._space_oauth_states) >= 4096:
oldest = min(self._space_oauth_states, key=lambda key: self._space_oauth_states[key][2])
self._space_oauth_states.pop(oldest, None)
self._space_oauth_states[digest] = (purpose, account_uuid, expires_at)
return raw_state
async def consume_space_oauth_state(
self,
raw_state: str,
purpose: typing.Literal['login', 'bind'],
) -> user.User | None:
"""Atomically consume OAuth state and resolve its active bind Account."""
if not isinstance(raw_state, str) or not raw_state:
raise ValueError('Invalid or expired OAuth state')
digest = self._space_oauth_state_digest(raw_state)
async with self._space_oauth_state_lock:
entry = self._space_oauth_states.pop(digest, None)
if entry is None or entry[0] != purpose or entry[2] <= time.monotonic():
raise ValueError('Invalid or expired OAuth state')
if purpose == 'login':
return None
account_uuid = entry[1]
account = await self.get_user_by_uuid(account_uuid or '')
if account is None:
raise ValueError('Invalid or expired OAuth state')
self._require_active_account(account)
return account
async def _hash_password(self, password: str) -> str:
async with self._password_hash_lock:
return await asyncio.to_thread(argon2.PasswordHasher().hash, password)
def _require_local_directory(self) -> None:
workspace_service = getattr(self.ap, 'workspace_service', None)
if workspace_service is not None and workspace_service.policy.multi_workspace_enabled:
raise ControlPlaneDirectoryRequiredError(
'Cloud Accounts and directory changes are managed by the SaaS control plane'
)
async def _verify_password(self, hashed_password: str, password: str) -> None:
async with self._password_hash_lock:
await asyncio.to_thread(argon2.PasswordHasher().verify, hashed_password, password)
async def _update_space_provider_for_account(self, account: typing.Any, api_key: str) -> None:
"""Refresh the OSS Workspace Space provider without guessing a SaaS Workspace.
Space OAuth credentials belong to an Account, while model-provider secrets
belong to a Workspace. Community edition has one unambiguous Workspace, so
the historical automatic refresh remains available to members allowed to
manage provider secrets. In multi-Workspace SaaS mode the OAuth callback has
no trusted Workspace selector; the closed control plane or an explicit
Workspace settings action must perform that linkage instead.
"""
workspace_service = getattr(self.ap, 'workspace_service', None)
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
account_uuid = getattr(account, 'uuid', None)
if workspace_service is None or collaboration_service is None or not isinstance(account_uuid, str):
# Never turn a missing tenant kernel into a global secret mutation.
return
if workspace_service.policy.multi_workspace_enabled:
return
accesses = await collaboration_service.list_account_workspaces(account_uuid)
if len(accesses) != 1:
return
access = accesses[0]
if Permission.PROVIDER_SECRET_MANAGE.value not in permissions_for_role(access.membership.role):
return
await self.ap.provider_service.update_space_model_provider_api_keys(
access.workspace.uuid,
api_key,
)
async def is_initialized(self) -> bool:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
result_list = result.all()
return result_list is not None and len(result_list) > 0
def _session_factory(self) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
def _jwt_identity(self) -> tuple[str, str]:
workspace_service = getattr(self.ap, 'workspace_service', None)
instance_uuid = str(getattr(workspace_service, 'instance_uuid', '') or constants.instance_id).strip()
# UserService is constructed only after config/bootstrap in production.
# The fallback keeps lightweight isolated unit tests deterministic.
if not instance_uuid:
instance_uuid = 'uninitialized-test-instance'
return 'langbot-core', f'langbot-instance:{instance_uuid}'
def _legacy_local_tokens_allowed(self) -> bool:
workspace_service = getattr(self.ap, 'workspace_service', None)
policy = getattr(workspace_service, 'policy', None)
return getattr(policy, 'multi_workspace_enabled', False) is not True
async def create_user(self, user_email: str, password: str) -> None:
"""Create the first local Account and Workspace owner atomically."""
await self.create_initial_account(user_email, password)
async def create_initial_account(self, user_email: str, password: str) -> user.User:
self._require_local_directory()
normalized_email = normalize_email(user_email)
hashed_password = await self._hash_password(password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')
async with self._create_user_lock:
async with self._session_factory()() as session:
async with session.begin():
existing_count = int(
(await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(user.User))) or 0
)
if existing_count:
raise PublicRegistrationClosedError('System already initialized')
account = self._new_account(normalized_email, hashed_password)
session.add(account)
await session.flush()
await self.ap.workspace_service.bootstrap_local_account(account.uuid, session=session)
return account
async def register_invited_account(
self,
invitation_token: str,
user_email: str,
password: str,
) -> tuple[user.User, typing.Any, str]:
"""Create an invited Account and accept its Membership in one transaction."""
self._require_local_directory()
normalized_email = normalize_email(user_email)
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
if invitation.normalized_email != normalized_email:
from ....workspace.collaboration import InvitationEmailMismatchError
raise InvitationEmailMismatchError('Invitation email does not match the Account')
hashed_password = await self._hash_password(password)
async with self._create_user_lock:
async with self._session_factory()() as session:
async with session.begin():
existing = await session.scalar(
sqlalchemy.select(user.User).where(user.User.normalized_email == normalized_email)
)
if existing is not None:
raise AccountExistsLoginRequiredError('An Account already exists for this email')
account = self._new_account(normalized_email, hashed_password)
session.add(account)
await session.flush()
membership = await self.ap.workspace_collaboration_service.accept_invitation(
invitation_token,
account.uuid,
session=session,
)
token = await self.generate_jwt_token(account)
return account, membership, token
def _new_account(self, normalized_email: str, hashed_password: str) -> user.User:
return user.User(
uuid=str(uuid.uuid4()),
user=normalized_email,
normalized_email=normalized_email,
password=hashed_password,
account_type='local',
status=user.AccountStatus.ACTIVE.value,
source=user.AccountSource.LOCAL.value,
projection_revision=0,
)
async def get_user_by_email(self, user_email: str) -> user.User | None:
normalized_email = user_email.strip().casefold()
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.user == user_email)
sqlalchemy.select(user.User).where(user.User.normalized_email == normalized_email)
)
result_list = result.all()
return result_list[0] if result_list is not None and len(result_list) > 0 else None
async def get_user_by_uuid(self, account_uuid: str) -> user.User | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.uuid == account_uuid)
)
return result.first()
async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None:
"""Get user by Space account UUID"""
result = await self.ap.persistence_mgr.execute_async(
@@ -61,16 +270,10 @@ class UserService:
return result_list[0] if result_list is not None and len(result_list) > 0 else None
async def authenticate(self, user_email: str, password: str) -> str | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.user == user_email)
)
result_list = result.all()
if result_list is None or len(result_list) == 0:
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
raise ValueError('用户不存在')
user_obj = result_list[0]
self._require_active_account(user_obj)
# Check if this user has a local password set
if not user_obj.password:
@@ -78,30 +281,119 @@ class UserService:
await self._verify_password(user_obj.password, password)
return await self.generate_jwt_token(user_email)
return await self.generate_jwt_token(user_obj)
async def generate_jwt_token(self, user_email: str) -> str:
async def generate_jwt_token(self, account: user.User | str) -> str:
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
jwt_expire = self.ap.instance_config.data['system']['jwt']['expire']
account_obj: user.User | None = account if not isinstance(account, str) and hasattr(account, 'user') else None
user_email = account_obj.user if account_obj is not None else account
if account_obj is None and hasattr(self.ap, 'persistence_mgr'):
try:
account_obj = await self.get_user_by_email(user_email)
except (AttributeError, TypeError):
# Lightweight unit-test and bootstrap callers may not have persistence wired.
account_obj = None
payload = {
'user': user_email,
'iss': 'LangBot-' + constants.edition,
'iss': self._jwt_identity()[0],
'aud': self._jwt_identity()[1],
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=jwt_expire),
}
if account_obj is not None:
self._require_active_account(account_obj)
payload.update(
{
'sub': account_obj.uuid,
'account_revision': account_obj.projection_revision,
}
)
return jwt.encode(payload, jwt_secret, algorithm='HS256')
async def verify_jwt_token(self, token: str) -> str:
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
account = await self.get_authenticated_account(token, allow_unresolved_legacy=True)
if isinstance(account, str):
return account
return account.user
return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user']
async def get_authenticated_account(
self,
token: str,
*,
allow_unresolved_legacy: bool = False,
) -> user.User | str:
"""Resolve a JWT to an active Account, accepting bounded legacy email tokens."""
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
issuer, audience = self._jwt_identity()
try:
payload = jwt.decode(
token,
jwt_secret,
algorithms=['HS256'],
issuer=issuer,
audience=audience,
options={'require': ['exp', 'iss', 'aud']},
)
except jwt.MissingRequiredClaimError:
# Preserve one bounded OSS upgrade path for previously issued
# community tokens. SaaS/Cloud policy never accepts these tokens,
# and a token carrying a new-style or foreign audience cannot fall
# back into the legacy decoder.
unverified = jwt.decode(token, options={'verify_signature': False})
if (
not self._legacy_local_tokens_allowed()
or 'aud' in unverified
or unverified.get('iss') != 'LangBot-community'
):
raise
payload = jwt.decode(
token,
jwt_secret,
algorithms=['HS256'],
options={'require': ['exp'], 'verify_aud': False, 'verify_iss': False},
)
account_obj: user.User | None = None
account_uuid = payload.get('sub')
if isinstance(account_uuid, str) and account_uuid:
try:
account_obj = await self.get_user_by_uuid(account_uuid)
except AttributeError:
account_obj = None
if account_obj is None:
legacy_email = payload.get('user')
if not isinstance(legacy_email, str) or not legacy_email:
raise ValueError('JWT Account identity is missing')
try:
account_obj = await self.get_user_by_email(legacy_email)
except AttributeError:
account_obj = None
if account_obj is None and allow_unresolved_legacy:
return legacy_email
if account_obj is None:
raise ValueError('Account not found')
self._require_active_account(account_obj)
token_revision = payload.get('account_revision')
if token_revision is not None and int(token_revision) != account_obj.projection_revision:
raise ValueError('Account token revision is stale')
return account_obj
@staticmethod
def _require_active_account(account: user.User) -> None:
status = getattr(account, 'status', user.AccountStatus.ACTIVE.value)
if isinstance(status, str) and status != user.AccountStatus.ACTIVE.value:
raise AccountDisabledError('Account is disabled')
async def reset_password(self, user_email: str, new_password: str) -> None:
hashed_password = await self._hash_password(new_password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
sqlalchemy.update(user.User)
.where(user.User.normalized_email == normalize_email(user_email))
.values(password=hashed_password)
)
async def change_password(self, user_email: str, current_password: str, new_password: str) -> None:
@@ -117,7 +409,9 @@ class UserService:
hashed_password = await self._hash_password(new_password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
sqlalchemy.update(user.User)
.where(user.User.normalized_email == normalize_email(user_email))
.values(password=hashed_password)
)
# Space user management
@@ -132,6 +426,7 @@ class UserService:
expires_in: int = 0,
) -> user.User:
"""Create or update a Space user account (only if system not initialized or user exists)"""
self._require_local_directory()
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
async with self._create_user_lock:
@@ -150,17 +445,53 @@ class UserService:
space_access_token_expires_at=expires_at,
)
)
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
await self._update_space_provider_for_account(existing_user, api_key)
return await self.get_user_by_space_account_uuid(space_account_uuid)
# Check if user with same email exists
existing_email_user = await self.get_user_by_email(email)
if existing_email_user:
# Update existing user to link with Space account
# Email is display/contact identity, not an OAuth subject. An
# unknown Space subject must never take over an existing local
# Account merely by presenting the same email. The Account
# owner must first authenticate locally and use the explicit,
# account-bound bind flow.
raise account_errors.AccountEmailMismatchError()
# Check if system is already initialized
is_initialized = await self.is_initialized()
if is_initialized:
raise account_errors.AccountEmailMismatchError()
# Create new Space user (first time initialization)
if hasattr(self.ap.persistence_mgr, 'get_db_engine') and hasattr(self.ap, 'workspace_service'):
async with self._session_factory()() as session:
async with session.begin():
account = user.User(
uuid=str(uuid.uuid4()),
user=normalize_email(email),
normalized_email=normalize_email(email),
password='',
account_type='space',
status=user.AccountStatus.ACTIVE.value,
source=user.AccountSource.LOCAL.value,
projection_revision=0,
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
)
session.add(account)
await session.flush()
await self.ap.workspace_service.bootstrap_local_account(account.uuid, session=session)
else:
# Compatibility path for lightweight service tests without a real engine.
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.user == email)
.values(
sqlalchemy.insert(user.User).values(
user=normalize_email(email),
normalized_email=normalize_email(email),
password='',
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
@@ -169,30 +500,10 @@ class UserService:
space_access_token_expires_at=expires_at,
)
)
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
return await self.get_user_by_email(email)
# Check if system is already initialized
is_initialized = await self.is_initialized()
if is_initialized:
raise account_errors.AccountEmailMismatchError()
# Create new Space user (first time initialization)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(user.User).values(
user=email,
password='', # Space users don't have local password
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
)
)
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
return await self.get_user_by_space_account_uuid(space_account_uuid)
created_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if created_user is not None:
await self._update_space_provider_for_account(created_user, api_key)
return created_user
async def authenticate_space_user(
self, access_token: str, refresh_token: str, expires_in: int = 0
@@ -221,7 +532,7 @@ class UserService:
)
# Generate JWT token
jwt_token = await self.generate_jwt_token(email)
jwt_token = await self.generate_jwt_token(user_obj)
return jwt_token, user_obj
@@ -247,11 +558,16 @@ class UserService:
hashed_password = await self._hash_password(new_password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
sqlalchemy.update(user.User)
.where(user.User.normalized_email == normalize_email(user_email))
.values(password=hashed_password)
)
async def bind_space_account(self, user_email: str, code: str) -> user.User:
"""Bind Space account to existing local account"""
local_account = await self.get_user_by_email(user_email)
if local_account is None:
raise ValueError('User not found')
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
@@ -276,15 +592,16 @@ class UserService:
# Check if this Space account is already bound to another user
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_space_user and existing_space_user.user != user_email:
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
raise ValueError('This Space account is already bound to another user')
# Update local account to Space account
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.user == user_email)
.where(user.User.normalized_email == normalize_email(user_email))
.values(
user=space_email, # Update email to Space email
user=normalize_email(space_email), # Update email to Space email
normalized_email=normalize_email(space_email),
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
@@ -295,6 +612,6 @@ class UserService:
)
# Update Space model provider API keys
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
await self._update_space_provider_for_account(local_account, api_key)
return await self.get_user_by_email(space_email)
+87 -19
View File
@@ -4,6 +4,8 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import webhook
from .secrets import SECRET_MASK, mask_secret_value, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
class WebhookService:
@@ -12,31 +14,71 @@ class WebhookService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_webhooks(self) -> list[dict]:
def _serialize_webhook(self, entity, *, include_secret: bool) -> dict:
serialized = self.ap.persistence_mgr.serialize_model(webhook.Webhook, entity)
if not include_secret:
serialized = serialized.copy()
serialized['url'] = mask_secret_value(serialized.get('url'))
return serialized
async def get_webhooks(self, context: TenantContext, *, include_secret: bool = False) -> list[dict]:
"""Get all webhooks"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(webhook.Webhook))
result = await self.ap.persistence_mgr.execute_async(
scope_statement(sqlalchemy.select(webhook.Webhook), webhook.Webhook, context)
)
webhooks = result.all()
return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks]
return [self._serialize_webhook(wh, include_secret=include_secret) for wh in webhooks]
async def create_webhook(self, name: str, url: str, description: str = '', enabled: bool = True) -> dict:
async def create_webhook(
self,
context: TenantContext,
name: str,
url: str,
description: str = '',
enabled: bool = True,
) -> dict:
"""Create a new webhook"""
webhook_data = {'name': name, 'url': url, 'description': description, 'enabled': enabled}
workspace_uuid = require_workspace_uuid(context)
url = restore_secret_placeholders(url, sensitive=True)
webhook_data = {
'workspace_uuid': workspace_uuid,
'name': name,
'url': url,
'description': description,
'enabled': enabled,
}
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(webhook.Webhook).values(**webhook_data))
insert_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(webhook.Webhook).values(**webhook_data)
)
# Retrieve the created webhook
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.url == url).order_by(webhook.Webhook.id.desc())
scope_statement(
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == insert_result.inserted_primary_key[0]),
webhook.Webhook,
workspace_uuid,
)
)
created_webhook = result.first()
return self.ap.persistence_mgr.serialize_model(webhook.Webhook, created_webhook)
async def get_webhook(self, webhook_id: int) -> dict | None:
async def get_webhook(
self,
context: TenantContext,
webhook_id: int,
*,
include_secret: bool = False,
) -> dict | None:
"""Get a specific webhook by ID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id)
scope_statement(
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id),
webhook.Webhook,
context,
)
)
wh = result.first()
@@ -44,16 +86,27 @@ class WebhookService:
if wh is None:
return None
return self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh)
return self._serialize_webhook(wh, include_secret=include_secret)
async def update_webhook(
self, webhook_id: int, name: str = None, url: str = None, description: str = None, enabled: bool = None
) -> None:
self,
context: TenantContext,
webhook_id: int,
name: str | None = None,
url: str | None = None,
description: str | None = None,
enabled: bool | None = None,
) -> bool:
"""Update a webhook's metadata"""
update_data = {}
if name is not None:
update_data['name'] = name
if url is not None:
if url == SECRET_MASK:
current = await self.get_webhook(context, webhook_id, include_secret=True)
if current is None:
return False
url = restore_secret_placeholders(url, current.get('url'), sensitive=True)
update_data['url'] = url
if description is not None:
update_data['description'] = description
@@ -61,20 +114,35 @@ class WebhookService:
update_data['enabled'] = enabled
if update_data:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data),
webhook.Webhook,
context,
)
)
return (result.rowcount or 0) > 0
return await self.get_webhook(context, webhook_id) is not None
async def delete_webhook(self, webhook_id: int) -> None:
async def delete_webhook(self, context: TenantContext, webhook_id: int) -> bool:
"""Delete a webhook"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id),
webhook.Webhook,
context,
)
)
return (result.rowcount or 0) > 0
async def get_enabled_webhooks(self) -> list[dict]:
async def get_enabled_webhooks(self, context: TenantContext) -> list[dict]:
"""Get all enabled webhooks"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True)
scope_statement(
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True),
webhook.Webhook,
context,
)
)
webhooks = result.all()
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import contextvars
from ..http.context import RequestContext
_request_context: contextvars.ContextVar[RequestContext | None] = contextvars.ContextVar(
'langbot_mcp_request_context',
default=None,
)
def bind_request_context(context: RequestContext) -> contextvars.Token[RequestContext | None]:
"""Bind the authenticated MCP request while its ASGI request is executing."""
return _request_context.set(context)
def reset_request_context(token: contextvars.Token[RequestContext | None]) -> None:
_request_context.reset(token)
def get_request_context() -> RequestContext:
"""Return the current trusted MCP context or fail closed."""
context = _request_context.get()
if context is None:
raise RuntimeError('MCP Workspace context is unavailable')
return context
+28 -5
View File
@@ -19,7 +19,10 @@ from __future__ import annotations
import contextlib
import typing
import uuid
from ..http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from .context import bind_request_context, reset_request_context
from .server import LangBotMCPServer
if typing.TYPE_CHECKING:
@@ -76,7 +79,7 @@ class MCPMount:
def wrap(self, quart_asgi: typing.Callable) -> typing.Callable:
"""Return a dispatcher ASGI app fronting ``quart_asgi``."""
mcp_asgi = self._mcp_asgi
verify_api_key = self.ap.apikey_service.verify_api_key
authenticate_api_key = self.ap.apikey_service.authenticate_api_key
is_mcp_path = self._is_mcp_path
async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def]
@@ -88,12 +91,12 @@ class MCPMount:
# Authenticate MCP HTTP requests with a LangBot API key.
api_key = _extract_api_key(scope.get('headers', []))
authorized = False
identity = None
if api_key:
with contextlib.suppress(Exception):
authorized = await verify_api_key(api_key)
identity = await authenticate_api_key(api_key)
if not authorized:
if identity is None:
await send(
{
'type': 'http.response.start',
@@ -107,6 +110,26 @@ class MCPMount:
await send({'type': 'http.response.body', 'body': _UNAUTHORIZED_BODY})
return
await mcp_asgi(scope, receive, send)
request_context = RequestContext(
instance_uuid=identity.instance_uuid,
placement_generation=identity.placement_generation,
request_id=str(uuid.uuid4()),
auth_type='api-key',
principal=PrincipalContext(
principal_type=PrincipalType.API_KEY,
api_key_uuid=identity.api_key_uuid,
),
workspace=WorkspaceContext(
workspace_uuid=identity.workspace_uuid,
membership_uuid=None,
role=None,
permissions=identity.permissions,
),
)
token = bind_request_context(request_context)
try:
await mcp_asgi(scope, receive, send)
finally:
reset_request_context(token)
return dispatcher
+50 -20
View File
@@ -22,6 +22,9 @@ import typing
from mcp.server.fastmcp import FastMCP
from ..http.authz import Permission, require_permission
from .context import get_request_context
if typing.TYPE_CHECKING:
from ...core import app as app_module
@@ -46,6 +49,12 @@ def _dump(value: typing.Any) -> str:
return json.dumps(value, ensure_ascii=False, default=str)
def _authorized(permission: Permission):
context = get_request_context()
require_permission(context, permission)
return context
class LangBotMCPServer:
"""Builds and owns the FastMCP instance for LangBot."""
@@ -72,6 +81,7 @@ class LangBotMCPServer:
# ----- System (read-only) -------------------------------------- #
@mcp.tool(description='Get basic LangBot system/runtime information (version, edition).')
async def get_system_info() -> str:
_authorized(Permission.WORKSPACE_VIEW)
version = None
try:
version = ap.ver_mgr.get_current_version()
@@ -87,11 +97,13 @@ class LangBotMCPServer:
# ----- Bots ---------------------------------------------------- #
@mcp.tool(description='List all messaging-platform bots. Secrets are redacted.')
async def list_bots() -> str:
return _dump(await ap.bot_service.get_bots(include_secret=False))
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.bot_service.get_bots(context, include_secret=False))
@mcp.tool(description='Get a single bot by its UUID. Secrets are redacted.')
async def get_bot(bot_uuid: str) -> str:
return _dump(await ap.bot_service.get_bot(bot_uuid, include_secret=False))
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.bot_service.get_bot(context, bot_uuid, include_secret=False))
@mcp.tool(
description=(
@@ -101,26 +113,31 @@ class LangBotMCPServer:
)
)
async def create_bot(bot_data: dict) -> str:
return _dump({'uuid': await ap.bot_service.create_bot(bot_data)})
context = _authorized(Permission.RESOURCE_MANAGE)
return _dump({'uuid': await ap.bot_service.create_bot(context, bot_data)})
@mcp.tool(description='Update a bot by UUID. `bot_data` matches the PUT bot body.')
async def update_bot(bot_uuid: str, bot_data: dict) -> str:
await ap.bot_service.update_bot(bot_uuid, bot_data)
context = _authorized(Permission.RESOURCE_MANAGE)
await ap.bot_service.update_bot(context, bot_uuid, bot_data)
return _dump({'ok': True})
@mcp.tool(description='Delete a bot by UUID.')
async def delete_bot(bot_uuid: str) -> str:
await ap.bot_service.delete_bot(bot_uuid)
context = _authorized(Permission.RESOURCE_MANAGE)
await ap.bot_service.delete_bot(context, bot_uuid)
return _dump({'ok': True})
# ----- Pipelines ----------------------------------------------- #
@mcp.tool(description='List all pipelines.')
async def list_pipelines() -> str:
return _dump(await ap.pipeline_service.get_pipelines())
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.pipeline_service.get_pipelines(context))
@mcp.tool(description='Get a single pipeline by UUID.')
async def get_pipeline(pipeline_uuid: str) -> str:
return _dump(await ap.pipeline_service.get_pipeline(pipeline_uuid))
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.pipeline_service.get_pipeline(context, pipeline_uuid))
@mcp.tool(
description=(
@@ -129,49 +146,59 @@ class LangBotMCPServer:
)
)
async def create_pipeline(pipeline_data: dict) -> str:
return _dump({'uuid': await ap.pipeline_service.create_pipeline(pipeline_data)})
context = _authorized(Permission.RESOURCE_MANAGE)
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
await ap.pipeline_service.update_pipeline(pipeline_uuid, pipeline_data)
context = _authorized(Permission.RESOURCE_MANAGE)
await ap.pipeline_service.update_pipeline(context, pipeline_uuid, pipeline_data)
return _dump({'ok': True})
@mcp.tool(description='Delete a pipeline by UUID.')
async def delete_pipeline(pipeline_uuid: str) -> str:
await ap.pipeline_service.delete_pipeline(pipeline_uuid)
context = _authorized(Permission.RESOURCE_MANAGE)
await ap.pipeline_service.delete_pipeline(context, pipeline_uuid)
return _dump({'ok': True})
# ----- Models -------------------------------------------------- #
@mcp.tool(description='List all configured LLM models. Secrets are redacted.')
async def list_llm_models() -> str:
return _dump(await ap.llm_model_service.get_llm_models(include_secret=False))
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.llm_model_service.get_llm_models(context, include_secret=False))
@mcp.tool(description='Get a single LLM model by UUID.')
async def get_llm_model(model_uuid: str) -> str:
return _dump(await ap.llm_model_service.get_llm_model(model_uuid))
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.llm_model_service.get_llm_model(context, model_uuid, include_secret=False))
@mcp.tool(description='List all configured embedding models.')
async def list_embedding_models() -> str:
return _dump(await ap.embedding_models_service.get_embedding_models())
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.embedding_models_service.get_embedding_models(context, include_secret=False))
@mcp.tool(description='List all model providers (OpenAI-compatible, Anthropic, etc.).')
async def list_model_providers() -> str:
return _dump(await ap.provider_service.get_providers())
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.provider_service.get_providers(context, include_secret=False))
# ----- Knowledge bases ----------------------------------------- #
@mcp.tool(description='List all knowledge bases (RAG).')
async def list_knowledge_bases() -> str:
return _dump(await ap.knowledge_service.get_knowledge_bases())
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.knowledge_service.get_knowledge_bases(context))
@mcp.tool(description='Get a single knowledge base by UUID.')
async def get_knowledge_base(kb_uuid: str) -> str:
return _dump(await ap.knowledge_service.get_knowledge_base(kb_uuid))
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.knowledge_service.get_knowledge_base(context, kb_uuid))
@mcp.tool(
description=('Retrieve (semantic search) from a knowledge base. Returns the matched chunks for `query`.')
)
async def retrieve_knowledge_base(kb_uuid: str, query: str) -> str:
return _dump(await ap.knowledge_service.retrieve_knowledge_base(kb_uuid, query))
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.knowledge_service.retrieve_knowledge_base(context, kb_uuid, query))
# ----- MCP servers (LangBot as MCP client) --------------------- #
@mcp.tool(
@@ -180,16 +207,19 @@ class LangBotMCPServer:
)
)
async def list_mcp_servers() -> str:
return _dump(await ap.mcp_service.get_mcp_servers())
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.mcp_service.get_mcp_servers(context))
# ----- Skills -------------------------------------------------- #
@mcp.tool(description='List installed skills.')
async def list_skills() -> str:
return _dump(await ap.skill_service.list_skills())
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.skill_service.list_skills(context))
@mcp.tool(description='Get a single skill by name.')
async def get_skill(skill_name: str) -> str:
return _dump(await ap.skill_service.get_skill(skill_name))
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.skill_service.get_skill(context, skill_name))
# ------------------------------------------------------------------ #
# ASGI app
+61 -2
View File
@@ -4,6 +4,7 @@ import asyncio
import contextlib
import json
import os
import secrets
import sys
import typing
from typing import TYPE_CHECKING
@@ -16,6 +17,17 @@ from langbot_plugin.runtime.io.connection import Connection
from langbot_plugin.box.client import ActionRPCBoxClient
from langbot_plugin.box.errors import BoxRuntimeUnavailableError
from langbot_plugin.box.actions import LangBotToBoxAction
from langbot_plugin.box.security import (
BOX_CONTROL_TOKEN_ENV,
BOX_CONTROL_TOKEN_HEADER,
BOX_INSTANCE_HEADER,
BOX_PLACEMENT_GENERATION_HEADER,
BOX_TRUSTED_INSTANCE_ENV,
BOX_WORKSPACE_HEADER,
normalize_instance_uuid,
validate_control_token,
)
from langbot_plugin.entities.io.context import ActionContext
from ..utils import platform
from ..utils.managed_runtime import ManagedRuntimeConnector
@@ -123,6 +135,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self._relay_host = parsed.hostname or '127.0.0.1'
self._relay_port = parsed.port or _DEFAULT_PORT
self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap))
self._trusted_instance_uuid = normalize_instance_uuid(self.ap.workspace_service.instance_uuid)
self._control_token = str(os.environ.get(BOX_CONTROL_TOKEN_ENV) or '').strip()
def uses_websocket(self) -> bool:
"""Whether the connector should use WebSocket to reach the Box runtime.
@@ -223,8 +237,11 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
from langbot_plugin.runtime.io.controllers.stdio.client import StdioClientController
self.ap.logger.info('Use stdio to connect to box runtime')
self._ensure_control_token(allow_generate=True)
python_path = sys.executable
env = os.environ.copy()
env[BOX_CONTROL_TOKEN_ENV] = self._control_token
env[BOX_TRUSTED_INSTANCE_ENV] = self._trusted_instance_uuid
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
@@ -259,7 +276,10 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
"""Launch box server as detached subprocess, then connect via WS (Windows)."""
self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws')
self._ensure_control_token(allow_generate=True)
env = os.environ.copy()
env[BOX_CONTROL_TOKEN_ENV] = self._control_token
env[BOX_TRUSTED_INSTANCE_ENV] = self._trusted_instance_uuid
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
@@ -282,6 +302,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
async def _connect_remote_ws(self) -> None:
"""Connect to a remote (or Docker) box server via WebSocket."""
self._ensure_control_token(allow_generate=False)
ws_url = self._resolve_rpc_ws_url()
self.ap.logger.info(f'Use WebSocket to connect to box runtime ({ws_url})')
await self._connect_ws(ws_url, 'WebSocket')
@@ -325,8 +346,11 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
if self.runtime_disconnect_callback is not None:
await self.runtime_disconnect_callback(self)
ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed)
self._ctrl = ctrl
ctrl = WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=on_connect_failed,
additional_headers=self.get_control_headers(),
)
self._ctrl_task = asyncio.create_task(
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error, self._generation))
)
@@ -339,6 +363,41 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
if connect_error:
raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}')
def _ensure_control_token(self, *, allow_generate: bool) -> str:
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
try:
self._control_token = validate_control_token(self._control_token)
except ValueError as exc:
raise BoxRuntimeUnavailableError(
f'{BOX_CONTROL_TOKEN_ENV} must be configured with a strong shared secret for an external Box runtime'
) from exc
return self._control_token
def get_control_headers(self) -> dict[str, str]:
"""Headers for the instance-authenticated RPC control handshake."""
self._ensure_control_token(allow_generate=False)
return {
BOX_CONTROL_TOKEN_HEADER: self._control_token,
BOX_INSTANCE_HEADER: self._trusted_instance_uuid,
}
def get_relay_headers(
self,
action_context: ActionContext,
) -> dict[str, str]:
"""Return authenticated, placement-scoped relay handshake headers."""
context = ActionContext.model_validate(action_context).without_installation()
if context.instance_uuid != self._trusted_instance_uuid:
raise BoxRuntimeUnavailableError('Box relay context belongs to another LangBot instance')
return {
**self.get_control_headers(),
BOX_WORKSPACE_HEADER: context.workspace_uuid,
BOX_PLACEMENT_GENERATION_HEADER: str(context.placement_generation),
}
def _make_connection_callback(
self,
transport_name: str,
+320 -67
View File
@@ -12,8 +12,12 @@ from typing import TYPE_CHECKING
import pydantic
from langbot_plugin.box.client import BoxRuntimeClient
from langbot_plugin.entities.io.context import ActionContext
from langbot_plugin.box.tenancy import box_namespace
from .connector import BoxRuntimeConnector, _get_box_config
from ..telemetry import features as telemetry_features
from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from langbot_plugin.box.errors import BoxError, BoxValidationError
from langbot_plugin.box.models import (
BUILTIN_PROFILES,
@@ -191,6 +195,69 @@ class BoxService:
return False
return not self._runtime_connector.uses_websocket()
@staticmethod
def _execution_context(context: TenantContext) -> ExecutionContext:
workspace_uuid = require_workspace_uuid(context)
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
generation = getattr(context, 'placement_generation', None)
if not instance_uuid:
raise BoxValidationError('Box operations require an explicit instance UUID')
if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
raise BoxValidationError('Box operations require a positive placement generation')
return ExecutionContext(
instance_uuid=instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=generation,
bot_uuid=getattr(context, 'bot_uuid', None),
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
query_uuid=getattr(context, 'query_uuid', None),
)
@classmethod
def _query_execution_context(cls, query: pipeline_query.Query) -> ExecutionContext:
return cls._execution_context(
ExecutionContext(
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
placement_generation=getattr(query, 'placement_generation', 0) or 0,
bot_uuid=getattr(query, 'bot_uuid', None),
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
query_uuid=getattr(query, 'query_uuid', None),
)
)
@classmethod
def _action_context(cls, context: TenantContext) -> ActionContext:
execution_context = cls._execution_context(context)
return ActionContext(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
)
async def _validated_execution_context(self, context: TenantContext) -> ExecutionContext:
"""Resolve and fence a tenant context before touching shared Box state."""
execution_context = self._execution_context(context)
binding = await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
if binding.instance_uuid != execution_context.instance_uuid:
raise BoxValidationError('Box execution context belongs to another LangBot instance')
if (
str(getattr(binding, 'workspace_uuid', '') or '') != execution_context.workspace_uuid
or getattr(binding, 'placement_generation', None) != execution_context.placement_generation
):
raise BoxValidationError('Box execution context belongs to a stale Workspace placement')
return execution_context
def _tenant_workspace(self, context: TenantContext) -> str | None:
if self.default_workspace is None:
return None
namespace = box_namespace(self._action_context(context))
return os.path.join(self.default_workspace, 'tenants', namespace)
async def execute_spec_payload(
self,
spec_payload: dict,
@@ -200,6 +267,14 @@ class BoxService:
) -> dict:
if not self._available:
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
execution_context = await self._validated_execution_context(self._query_execution_context(query))
spec_payload = dict(spec_payload)
if spec_payload.get('host_path') in (None, ''):
tenant_workspace = self._tenant_workspace(execution_context)
if tenant_workspace is not None:
spec_payload['host_path'] = tenant_workspace
if self.shares_filesystem_with_box:
os.makedirs(tenant_workspace, exist_ok=True)
try:
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
except BoxError as exc:
@@ -216,14 +291,17 @@ class BoxService:
self._record_error(exc, query)
raise
try:
result = await self.client.execute(spec)
result = await self.client.execute(
spec,
action_context=self._action_context(execution_context),
)
except BoxError as exc:
self._record_error(exc, query)
raise
try:
await self._enforce_workspace_quota(spec, phase='after execution')
except BoxError as exc:
await self._cleanup_exceeded_session(spec)
await self._cleanup_exceeded_session(execution_context, spec)
self._record_error(exc, query)
raise
self.ap.logger.info(
@@ -347,6 +425,26 @@ class BoxService:
return await self.execute_spec_payload(spec_payload, query)
async def execute_in_context(
self,
context: TenantContext,
spec_payload: dict,
*,
skip_host_mount_validation: bool = False,
) -> BoxExecutionResult:
"""Execute trusted internal Box work inside one Workspace namespace."""
execution_context = await self._validated_execution_context(context)
payload = dict(spec_payload)
if payload.get('host_path') in (None, ''):
tenant_workspace = self._tenant_workspace(execution_context)
if tenant_workspace is not None:
payload['host_path'] = tenant_workspace
if self.shares_filesystem_with_box:
os.makedirs(tenant_workspace, exist_ok=True)
spec = self.build_spec(payload, skip_host_mount_validation=skip_host_mount_validation)
return await self.client.execute(spec, action_context=self._action_context(execution_context))
# ── Attachment passthrough (inbound / outbound) ──────────────────
#
# IM/webchat attachments (images, voices, files) reach the LLM as
@@ -379,7 +477,7 @@ class BoxService:
# truncation). The host-filesystem path has no such limit.
_EXEC_FALLBACK_MAX_BYTES = 256 * 1024
def _host_query_dir(self, subdir: str, query_id) -> str | None:
def _host_query_dir(self, subdir: str, query: pipeline_query.Query) -> str | None:
"""Host path for ``/workspace/<subdir>/<query_id>`` when LangBot can
access the bind-mounted workspace directly, else ``None``.
@@ -389,10 +487,10 @@ class BoxService:
to the sandbox (and vice-versa). It is ``None`` / not a local dir for
E2B and remote runtimes, where we must fall back to the exec channel.
"""
root = self.default_workspace
root = self._tenant_workspace(self._query_execution_context(query))
if not root or not os.path.isdir(root):
return None
return os.path.join(root, subdir, str(query_id))
return os.path.join(root, subdir, str(query.query_id))
async def _purge_attachment_dirs(self) -> None:
"""Remove leftover inbox/outbox directories on startup.
@@ -402,12 +500,10 @@ class BoxService:
a previous process would otherwise be silently reused leaking a prior
run's inbound files and re-sending stale outbound files.
Outbox files are written by the sandbox **container**, which runs as
root over the bind-mount, so the LangBot host process (a non-root user)
cannot ``rmtree`` them. We therefore try a host-side delete first (fast,
works for host-owned inbox files) and, for anything that survives,
delete from *inside* the sandbox via exec where the container's root can
remove its own files. Best-effort: never block startup.
Tenant workspaces live below ``default_workspace/tenants``. Startup has
no authenticated Workspace context, so cleanup is deliberately limited
to direct host-filesystem deletion. It must never issue an unscoped Box
exec merely to remove root-owned container output.
"""
root = self.default_workspace
if not root or not os.path.isdir(root):
@@ -418,14 +514,29 @@ class BoxService:
host_survivors: list[str] = []
def _host_purge() -> list[str]:
candidates = [
os.path.join(root, self.INBOX_SUBDIR),
os.path.join(root, self.OUTBOX_SUBDIR),
]
tenants_root = os.path.join(root, 'tenants')
if os.path.isdir(tenants_root):
with os.scandir(tenants_root) as tenant_entries:
for tenant_entry in tenant_entries:
if not tenant_entry.is_dir(follow_symlinks=False):
continue
candidates.extend(
[
os.path.join(tenant_entry.path, self.INBOX_SUBDIR),
os.path.join(tenant_entry.path, self.OUTBOX_SUBDIR),
]
)
survivors: list[str] = []
for subdir in (self.INBOX_SUBDIR, self.OUTBOX_SUBDIR):
path = os.path.join(root, subdir)
for path in candidates:
if not os.path.isdir(path):
continue
shutil.rmtree(path, ignore_errors=True)
if os.path.exists(path):
survivors.append(subdir)
survivors.append(path)
return survivors
try:
@@ -438,18 +549,11 @@ class BoxService:
self.ap.logger.info('Purged leftover sandbox attachment dirs from a previous process.')
return
# Root-owned leftovers (container output): delete from inside the box.
targets = ' '.join(f'/workspace/{sub}' for sub in host_survivors)
try:
spec = self.build_spec({'cmd': f'rm -rf {targets}', 'session_id': '__startup_purge__', 'timeout_sec': 30})
await self.client.execute(spec)
self.ap.logger.info(
f'Purged root-owned leftover sandbox attachment dirs via sandbox exec: {host_survivors}'
)
except Exception as exc:
self.ap.logger.warning(
f'Failed to purge root-owned sandbox attachment dirs {host_survivors} via exec: {exc}'
)
self.ap.logger.warning(
'Could not purge root-owned sandbox attachment directories from the host; '
'skipping an unsafe unscoped Box exec because startup has no trusted '
f'Workspace context: {host_survivors}'
)
@staticmethod
def _sanitize_attachment_name(name: str, fallback: str) -> str:
@@ -529,7 +633,7 @@ class BoxService:
if not files:
return []
host_dir = self._host_query_dir(subdir, query.query_id)
host_dir = self._host_query_dir(subdir, query)
if host_dir is not None:
return await asyncio.to_thread(self._write_files_host, host_dir, target_mount_dir, files)
@@ -702,7 +806,7 @@ class BoxService:
if not self._available:
return []
host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query.query_id)
host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query)
if host_dir is not None:
entries = await asyncio.to_thread(self._read_outbox_host, host_dir)
else:
@@ -872,11 +976,12 @@ class BoxService:
elif self._runtime_connector is not None:
self._runtime_connector.dispose()
async def get_sessions(self) -> list[dict]:
async def get_sessions(self, context: TenantContext) -> list[dict]:
execution_context = await self._validated_execution_context(context)
if not self._available:
return []
try:
return await self.client.get_sessions()
return await self.client.get_sessions(action_context=self._action_context(execution_context))
except Exception:
return []
@@ -904,21 +1009,70 @@ class BoxService:
self._validate_host_mount(spec)
return spec
async def create_session(self, spec_payload: dict, *, skip_host_mount_validation: bool = False) -> dict:
async def create_session(
self,
context: TenantContext,
spec_payload: dict,
*,
skip_host_mount_validation: bool = False,
) -> dict:
execution_context = await self._validated_execution_context(context)
spec_payload = dict(spec_payload)
if spec_payload.get('host_path') in (None, ''):
tenant_workspace = self._tenant_workspace(execution_context)
if tenant_workspace is not None:
spec_payload['host_path'] = tenant_workspace
if self.shares_filesystem_with_box:
os.makedirs(tenant_workspace, exist_ok=True)
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
return await self.client.create_session(spec)
return await self.client.create_session(spec, action_context=self._action_context(execution_context))
async def start_managed_process(self, session_id: str, process_payload: dict) -> BoxManagedProcessInfo:
async def start_managed_process(
self,
context: TenantContext,
session_id: str,
process_payload: dict,
) -> BoxManagedProcessInfo:
execution_context = await self._validated_execution_context(context)
process_spec = BoxManagedProcessSpec.model_validate(process_payload)
return await self.client.start_managed_process(session_id, process_spec)
return await self.client.start_managed_process(
session_id,
process_spec,
action_context=self._action_context(execution_context),
)
async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo:
return await self.client.get_managed_process(session_id, process_id)
async def get_managed_process(
self,
context: TenantContext,
session_id: str,
process_id: str = 'default',
) -> BoxManagedProcessInfo:
execution_context = await self._validated_execution_context(context)
return await self.client.get_managed_process(
session_id,
process_id,
action_context=self._action_context(execution_context),
)
async def stop_managed_process(self, session_id: str, process_id: str = 'default') -> None:
return await self.client.stop_managed_process(session_id, process_id)
async def stop_managed_process(
self,
context: TenantContext,
session_id: str,
process_id: str = 'default',
) -> None:
execution_context = await self._validated_execution_context(context)
return await self.client.stop_managed_process(
session_id,
process_id,
action_context=self._action_context(execution_context),
)
def get_managed_process_websocket_url(self, session_id: str, process_id: str = 'default') -> str:
def _get_managed_process_websocket_url(
self,
context: TenantContext,
session_id: str,
process_id: str = 'default',
) -> str:
getter = getattr(self.client, 'get_managed_process_websocket_url', None)
if getter is None:
raise BoxValidationError('box runtime client does not support managed process websocket attach')
@@ -927,52 +1081,127 @@ class BoxService:
if self._runtime_connector is not None
else 'http://127.0.0.1:5410'
)
return getter(session_id, ws_relay_base_url, process_id)
return getter(
session_id,
ws_relay_base_url,
process_id,
action_context=self._action_context(context),
)
async def list_skills(self) -> list[dict]:
return await self.client.list_skills()
async def get_managed_process_websocket_connection(
self,
context: TenantContext,
session_id: str,
process_id: str = 'default',
) -> tuple[str, dict[str, str]]:
"""Resolve a relay URL and headers after fencing the placement.
async def get_skill(self, name: str) -> dict | None:
return await self.client.get_skill(name)
The shared Box control secret is transported only in headers. The
Workspace and generation headers bind the relay to the same trusted
execution context used by the action RPC that created the process.
"""
async def create_skill(self, skill: dict) -> dict:
return await self.client.create_skill(skill)
execution_context = await self._validated_execution_context(context)
if self._runtime_connector is None:
raise BoxValidationError(
'box runtime connector does not support authenticated managed process websocket attach'
)
action_context = self._action_context(execution_context)
return (
self._get_managed_process_websocket_url(
execution_context,
session_id,
process_id,
),
self._runtime_connector.get_relay_headers(action_context),
)
async def update_skill(self, name: str, skill: dict) -> dict:
return await self.client.update_skill(name, skill)
async def list_skills(self, context: TenantContext) -> list[dict]:
execution_context = await self._validated_execution_context(context)
return await self.client.list_skills(action_context=self._action_context(execution_context))
async def delete_skill(self, name: str) -> None:
await self.client.delete_skill(name)
async def get_skill(self, context: TenantContext, name: str) -> dict | None:
execution_context = await self._validated_execution_context(context)
return await self.client.get_skill(name, action_context=self._action_context(execution_context))
async def scan_skill_directory(self, path: str) -> dict:
return await self.client.scan_skill_directory(path)
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
execution_context = await self._validated_execution_context(context)
payload = dict(skill)
payload.pop('workspace_uuid', None)
return await self.client.create_skill(payload, action_context=self._action_context(execution_context))
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
execution_context = await self._validated_execution_context(context)
payload = dict(skill)
payload.pop('workspace_uuid', None)
return await self.client.update_skill(
name,
payload,
action_context=self._action_context(execution_context),
)
async def delete_skill(self, context: TenantContext, name: str) -> None:
execution_context = await self._validated_execution_context(context)
await self.client.delete_skill(name, action_context=self._action_context(execution_context))
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
execution_context = await self._validated_execution_context(context)
return await self.client.scan_skill_directory(path, action_context=self._action_context(execution_context))
async def list_skill_files(
self,
context: TenantContext,
name: str,
path: str = '.',
include_hidden: bool = False,
max_entries: int = 200,
) -> dict:
return await self.client.list_skill_files(name, path, include_hidden, max_entries)
execution_context = await self._validated_execution_context(context)
return await self.client.list_skill_files(
name,
path,
include_hidden,
max_entries,
action_context=self._action_context(execution_context),
)
async def read_skill_file(self, name: str, path: str) -> dict:
return await self.client.read_skill_file(name, path)
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
execution_context = await self._validated_execution_context(context)
return await self.client.read_skill_file(
name,
path,
action_context=self._action_context(execution_context),
)
async def write_skill_file(self, name: str, path: str, content: str) -> dict:
return await self.client.write_skill_file(name, path, content)
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
execution_context = await self._validated_execution_context(context)
return await self.client.write_skill_file(
name,
path,
content,
action_context=self._action_context(execution_context),
)
async def preview_skill_zip(
self,
context: TenantContext,
file_bytes: bytes,
filename: str,
source_subdir: str = '',
target_suffix: str = 'upload',
) -> list[dict]:
return await self.client.preview_skill_zip(file_bytes, filename, source_subdir, target_suffix)
execution_context = await self._validated_execution_context(context)
return await self.client.preview_skill_zip(
file_bytes,
filename,
source_subdir,
target_suffix,
action_context=self._action_context(execution_context),
)
async def install_skill_zip(
self,
context: TenantContext,
file_bytes: bytes,
filename: str,
source_paths: list[str] | None = None,
@@ -980,6 +1209,7 @@ class BoxService:
source_subdir: str = '',
target_suffix: str = 'upload',
) -> list[dict]:
execution_context = await self._validated_execution_context(context)
return await self.client.install_skill_zip(
file_bytes,
filename,
@@ -987,6 +1217,7 @@ class BoxService:
source_path,
source_subdir,
target_suffix,
action_context=self._action_context(execution_context),
)
def _serialize_result(self, result: BoxExecutionResult) -> dict:
@@ -1305,9 +1536,12 @@ class BoxService:
f'host_path={host_path} session_id={spec.session_id}'
)
async def _cleanup_exceeded_session(self, spec: BoxSpec) -> None:
async def _cleanup_exceeded_session(self, context: TenantContext, spec: BoxSpec) -> None:
try:
await self.client.delete_session(spec.session_id)
await self.client.delete_session(
spec.session_id,
action_context=self._action_context(context),
)
except Exception as exc:
self.ap.logger.warning(
'Failed to clean up Box session after workspace quota was exceeded: '
@@ -1324,11 +1558,19 @@ class BoxService:
'type': type(exc).__name__,
'message': str(exc),
'query_id': str(query.query_id),
'instance_uuid': str(getattr(query, 'instance_uuid', '') or ''),
'workspace_uuid': str(getattr(query, 'workspace_uuid', '') or ''),
}
)
def get_recent_errors(self) -> list[dict]:
return list(self._recent_errors)
def get_recent_errors(self, context: TenantContext) -> list[dict]:
execution_context = self._execution_context(context)
return [
error
for error in self._recent_errors
if error.get('instance_uuid') == execution_context.instance_uuid
and error.get('workspace_uuid') == execution_context.workspace_uuid
]
def get_system_guidance(self, query_id=None) -> str:
"""Return LLM system-prompt guidance for the exec tool.
@@ -1366,17 +1608,28 @@ class BoxService:
)
return guidance
async def get_status(self) -> dict:
async def get_backend_status(self) -> dict:
"""Return instance-level backend readiness without tenant resource data."""
if not self._available:
return {'available': False, 'enabled': self._enabled, 'connector_error': self._connector_error}
backend = await self.client.get_backend_info()
return {'available': bool(backend.get('available', False)), 'enabled': self._enabled, 'backend': backend}
async def get_status(self, context: TenantContext) -> dict:
execution_context = await self._validated_execution_context(context)
action_context = self._action_context(execution_context)
recent_error_count = len(self.get_recent_errors(execution_context))
if not self._available:
return {
'available': False,
'enabled': self._enabled,
'profile': self.profile.name,
'recent_error_count': len(self._recent_errors),
'recent_error_count': recent_error_count,
'connector_error': self._connector_error,
}
try:
runtime_status = await self.client.get_status()
runtime_status = await self.client.get_status(action_context=action_context)
except Exception as exc:
# RPC failed — the runtime likely just disconnected and the
# heartbeat hasn't flipped _available yet.
@@ -1384,7 +1637,7 @@ class BoxService:
'available': False,
'enabled': self._enabled,
'profile': self.profile.name,
'recent_error_count': len(self._recent_errors),
'recent_error_count': recent_error_count,
'connector_error': str(exc),
}
# Backend state can be unavailable even when the connector is healthy
@@ -1402,7 +1655,7 @@ class BoxService:
'available': backend_ok,
'enabled': self._enabled,
'profile': self.profile.name,
'recent_error_count': len(self._recent_errors),
'recent_error_count': recent_error_count,
}
if not backend_ok and 'connector_error' not in payload:
backend_name = backend_info.get('name') if backend_info else None
+20 -8
View File
@@ -274,6 +274,7 @@ class BoxWorkspaceSession:
def __init__(
self,
box_service,
execution_context,
session_id: str,
*,
host_path: str | None = None,
@@ -290,6 +291,7 @@ class BoxWorkspaceSession:
persistent: bool = False,
):
self.box_service = box_service
self.execution_context = execution_context
self.session_id = session_id
self.host_path = host_path
self.host_path_mode = host_path_mode
@@ -363,7 +365,7 @@ class BoxWorkspaceSession:
timeout_sec: int | None = None,
):
payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec)
return await self.box_service.client.execute(self.box_service.build_spec(payload))
return await self.box_service.execute_in_context(self.execution_context, payload)
async def execute_for_query(
self,
@@ -378,7 +380,7 @@ class BoxWorkspaceSession:
return await self.box_service.execute_spec_payload(payload, query)
async def create_session(self):
return await self.box_service.create_session(self.build_session_payload())
return await self.box_service.create_session(self.execution_context, self.build_session_payload())
def build_process_payload(
self,
@@ -415,16 +417,26 @@ class BoxWorkspaceSession:
):
payload = self.build_process_payload(command, args, env=env, cwd=cwd)
payload['process_id'] = process_id
return await self.box_service.start_managed_process(self.session_id, payload)
return await self.box_service.start_managed_process(self.execution_context, self.session_id, payload)
async def get_managed_process(self, process_id: str = 'default'):
return await self.box_service.get_managed_process(self.session_id, process_id)
return await self.box_service.get_managed_process(self.execution_context, self.session_id, process_id)
async def stop_managed_process(self, process_id: str = 'default') -> None:
await self.box_service.stop_managed_process(self.session_id, process_id)
await self.box_service.stop_managed_process(self.execution_context, self.session_id, process_id)
def get_managed_process_websocket_url(self, process_id: str = 'default') -> str:
return self.box_service.get_managed_process_websocket_url(self.session_id, process_id)
async def get_managed_process_websocket_connection(
self,
process_id: str = 'default',
) -> tuple[str, dict[str, str]]:
return await self.box_service.get_managed_process_websocket_connection(
self.execution_context,
self.session_id,
process_id,
)
async def cleanup(self) -> None:
await self.box_service.client.delete_session(self.session_id)
await self.box_service.client.delete_session(
self.session_id,
action_context=self.box_service._action_context(self.execution_context),
)
+5
View File
@@ -89,6 +89,7 @@ class CommandManager:
_admins = await self.ap.persistence_mgr.execute_async(
_sa.select(_BotAdmin).where(
_BotAdmin.workspace_uuid == query.workspace_uuid,
_BotAdmin.bot_uuid == (query.bot_uuid or ''),
_BotAdmin.launcher_type == query.launcher_type.value,
_BotAdmin.launcher_id == str(query.launcher_id),
@@ -98,7 +99,11 @@ class CommandManager:
privilege = 2
ctx = command_context.ExecuteContext(
instance_uuid=query.instance_uuid,
workspace_uuid=query.workspace_uuid,
placement_generation=query.placement_generation,
query_id=query.query_id,
query_uuid=query.query_uuid,
session=session,
command_text=command_text,
full_command_text=full_command_text,
+54 -14
View File
@@ -4,7 +4,7 @@ import logging
import asyncio
import traceback
import os
import contextlib
import sqlalchemy
from ..platform import botmgr as im_mgr
from ..platform.webhook_pusher import WebhookPusher
@@ -46,6 +46,10 @@ from ..vector import mgr as vectordb_mgr
from ..telemetry import telemetry as telemetry_module
from ..survey import manager as survey_module
from ..skill import manager as skill_mgr
from ..workspace import service as workspace_service_module
from ..workspace import collaboration as workspace_collaboration_module
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from ..entity.persistence.workspace import WorkspaceExecutionState, WorkspaceExecutionStatus
class Application:
@@ -120,6 +124,10 @@ class Application:
persistence_mgr: persistencemgr.PersistenceManager = None
workspace_service: workspace_service_module.WorkspaceService = None
workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None
vector_db_mgr: vectordb_mgr.VectorDBManager = None
http_ctrl: http_controller.HTTPController = None
@@ -237,16 +245,31 @@ class Application:
check_interval_seconds = check_interval_hours * 3600
while True:
try:
deleted = await self.monitoring_service.cleanup_expired_records(
retention_days,
batch_size=delete_batch_size,
)
total_deleted = sum(deleted.values())
if total_deleted > 0:
self.logger.info(
f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
f'(retention={retention_days}d): {deleted}'
execution_states = await self.persistence_mgr.execute_async(
sqlalchemy.select(WorkspaceExecutionState).where(
WorkspaceExecutionState.instance_uuid == self.workspace_service.instance_uuid,
WorkspaceExecutionState.state == WorkspaceExecutionStatus.ACTIVE.value,
WorkspaceExecutionState.write_fenced == sqlalchemy.false(),
)
)
for execution_state in execution_states.all():
context = ExecutionContext(
instance_uuid=execution_state.instance_uuid,
workspace_uuid=execution_state.workspace_uuid,
placement_generation=execution_state.active_generation,
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
)
deleted = await self.monitoring_service.cleanup_expired_records(
context,
retention_days,
batch_size=delete_batch_size,
)
total_deleted = sum(deleted.values())
if total_deleted > 0:
self.logger.info(
f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
f'for Workspace {context.workspace_uuid} (retention={retention_days}d): {deleted}'
)
except Exception as e:
self.logger.warning(f'Monitoring auto-cleanup error: {e}')
await asyncio.sleep(check_interval_seconds)
@@ -270,10 +293,27 @@ class Application:
check_interval_seconds = check_interval_hours * 3600
while True:
try:
deleted = await self.maintenance_service.cleanup_expired_files()
total_deleted = sum(deleted.values())
if total_deleted > 0:
self.logger.info(f'Storage maintenance: deleted expired files: {deleted}')
execution_states = await self.persistence_mgr.execute_async(
sqlalchemy.select(WorkspaceExecutionState).where(
WorkspaceExecutionState.instance_uuid == self.workspace_service.instance_uuid,
WorkspaceExecutionState.state == WorkspaceExecutionStatus.ACTIVE.value,
WorkspaceExecutionState.write_fenced == sqlalchemy.false(),
)
)
for execution_state in execution_states.all():
context = ExecutionContext(
instance_uuid=execution_state.instance_uuid,
workspace_uuid=execution_state.workspace_uuid,
placement_generation=execution_state.active_generation,
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
)
deleted = await self.maintenance_service.cleanup_expired_files(context)
total_deleted = sum(deleted.values())
if total_deleted > 0:
self.logger.info(
f'Storage maintenance for Workspace {context.workspace_uuid}: '
f'deleted expired files: {deleted}'
)
except Exception as e:
self.logger.warning(f'Storage maintenance error: {e}')
await asyncio.sleep(check_interval_seconds)
+42 -5
View File
@@ -37,6 +37,11 @@ from ...vector import mgr as vectordb_mgr
from .. import taskmgr
from ...telemetry import telemetry as telemetry_module
from ...survey import manager as survey_module
from ...workspace import service as workspace_service_module
from ...workspace import collaboration as workspace_collaboration_module
from ...workspace import policy as workspace_policy_module
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from ...api.http.authz import WorkspaceRequiredError
@stage.stage_class('BuildAppStage')
@@ -51,9 +56,6 @@ class BuildAppStage(stage.BootingStage):
discover.discover_blueprint('templates/components.yaml')
ap.discover = discover
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
space_service_inst = space_service.SpaceService(ap)
ap.space_service = space_service_inst
@@ -98,8 +100,6 @@ class BuildAppStage(stage.BootingStage):
await ver_mgr.initialize()
ap.ver_mgr = ver_mgr
ap.query_pool = pool.QueryPool()
log_cache = logcache.LogCache()
ap.log_cache = log_cache
@@ -111,6 +111,43 @@ class BuildAppStage(stage.BootingStage):
ap.persistence_mgr = persistence_mgr_inst
await persistence_mgr_inst.initialize()
# The open-source Core is intentionally single-Workspace. A mutable
# config value such as ``system.edition`` is product metadata, not a
# trust credential, and must never activate SaaS routing. The closed
# Cloud bootstrap will install its verified policy only after checking
# a signed InstanceManifest; until that bootstrap exists, fail closed.
workspace_policy = workspace_policy_module.open_core_workspace_policy()
workspace_service_inst = workspace_service_module.WorkspaceService(
ap,
policy=workspace_policy,
)
if not workspace_policy.multi_workspace_enabled:
await workspace_service_inst.ensure_singleton_workspace()
ap.workspace_service = workspace_service_inst
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
ap,
workspace_service_inst,
)
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
async def resolve_singleton_execution_context() -> ExecutionContext:
if workspace_policy.multi_workspace_enabled:
raise WorkspaceRequiredError('Cloud runtime work requires an explicit Workspace context')
binding = await workspace_service_inst.get_local_execution_binding()
return ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
)
ap.query_pool = pool.QueryPool(
singleton_context_resolver=resolve_singleton_execution_context,
)
# Telemetry manager: attach to app so other components can call via self.ap.telemetry
telemetry_inst = telemetry_module.TelemetryManager(ap)
await telemetry_inst.initialize()
+71 -5
View File
@@ -98,6 +98,15 @@ class TaskWrapper:
scopes: list[core_entities.LifecycleControlScope]
"""Task scope"""
instance_uuid: str | None
"""Owning LangBot instance for a tenant user task."""
workspace_uuid: str | None
"""Owning Workspace for a tenant user task."""
placement_generation: int | None
"""Workspace execution fence captured when the task was created."""
def __init__(
self,
ap: app.Application,
@@ -108,6 +117,9 @@ class TaskWrapper:
label: str = '',
context: TaskContext = None,
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
instance_uuid: str | None = None,
workspace_uuid: str | None = None,
placement_generation: int | None = None,
):
self.id = TaskWrapper._id_index
TaskWrapper._id_index += 1
@@ -120,6 +132,9 @@ class TaskWrapper:
self.label = label if label != '' else name
self.task.set_name(name)
self.scopes = scopes
self.instance_uuid = instance_uuid
self.workspace_uuid = workspace_uuid
self.placement_generation = placement_generation
self.created_at = time.time()
def assume_exception(self):
@@ -155,6 +170,8 @@ class TaskWrapper:
'kind': self.kind,
'name': self.name,
'label': self.label,
'workspace_uuid': self.workspace_uuid,
'placement_generation': self.placement_generation,
'scopes': [scope.value for scope in self.scopes],
'created_at': self.created_at,
'task_context': self.task_context.to_dict(),
@@ -193,8 +210,23 @@ class AsyncTaskManager:
label: str = '',
context: TaskContext = None,
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
instance_uuid: str | None = None,
workspace_uuid: str | None = None,
placement_generation: int | None = None,
) -> TaskWrapper:
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
wrapper = TaskWrapper(
self.ap,
coro,
task_type,
kind,
name,
label,
context,
scopes,
instance_uuid,
workspace_uuid,
placement_generation,
)
self.tasks.append(wrapper)
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
self._prune_completed_tasks()
@@ -208,8 +240,22 @@ class AsyncTaskManager:
label: str = '',
context: TaskContext = None,
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
instance_uuid: str | None = None,
workspace_uuid: str | None = None,
placement_generation: int | None = None,
) -> TaskWrapper:
return self.create_task(coro, 'user', kind, name, label, context, scopes)
return self.create_task(
coro,
'user',
kind,
name,
label,
context,
scopes,
instance_uuid,
workspace_uuid,
placement_generation,
)
async def wait_all(self):
await asyncio.gather(*[t.task for t in self.tasks], return_exceptions=True)
@@ -221,12 +267,20 @@ class AsyncTaskManager:
self,
type: str = None,
kind: str = None,
*,
instance_uuid: str | None = None,
workspace_uuid: str | None = None,
placement_generation: int | None = None,
) -> dict:
return {
'tasks': [
t.to_dict()
for t in self.tasks
if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
if (type is None or t.task_type == type)
and (kind is None or t.kind == kind)
and (instance_uuid is None or t.instance_uuid == instance_uuid)
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
and (placement_generation is None or t.placement_generation == placement_generation)
],
'id_index': TaskWrapper._id_index,
}
@@ -240,9 +294,21 @@ class AsyncTaskManager:
'id_index': TaskWrapper._id_index,
}
def get_task_by_id(self, id: int) -> TaskWrapper | None:
def get_task_by_id(
self,
id: int,
*,
instance_uuid: str | None = None,
workspace_uuid: str | None = None,
placement_generation: int | None = None,
) -> TaskWrapper | None:
for t in self.tasks:
if t.id == id:
if (
t.id == id
and (instance_uuid is None or t.instance_uuid == instance_uuid)
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
and (placement_generation is None or t.placement_generation == placement_generation)
):
return t
return None
+45 -1
View File
@@ -1,16 +1,47 @@
import enum
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class ApiKeyStatus(enum.StrEnum):
ACTIVE = 'active'
REVOKED = 'revoked'
def _new_uuid() -> str:
return str(uuid_lib.uuid4())
class ApiKey(Base):
"""API Key for external service authentication"""
__tablename__ = 'api_keys'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False, default=_new_uuid)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
created_by_account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='SET NULL'),
nullable=True,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True)
key_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
scopes = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=list, server_default='[]')
status = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=ApiKeyStatus.ACTIVE.value,
)
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
@@ -19,3 +50,16 @@ class ApiKey(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.Index('uq_api_keys_uuid', 'uuid', unique=True),
# Authentication begins with the presented secret, before a Workspace
# can be trusted, so hashes remain globally unique.
sqlalchemy.Index('uq_api_keys_key_hash', 'key_hash', unique=True),
sqlalchemy.Index('ix_api_keys_workspace_name', 'workspace_uuid', 'name'),
sqlalchemy.Index('ix_api_keys_workspace_status', 'workspace_uuid', 'status'),
sqlalchemy.CheckConstraint(
"status IN ('active', 'revoked')",
name='ck_api_keys_status',
),
)
+30 -1
View File
@@ -9,12 +9,31 @@ class BotAdmin(Base):
__tablename__ = 'bot_admins'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
__table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),)
__table_args__ = (
sqlalchemy.UniqueConstraint(
'workspace_uuid',
'bot_uuid',
'launcher_type',
'launcher_id',
name='uq_bot_admin',
),
sqlalchemy.ForeignKeyConstraint(
['workspace_uuid', 'bot_uuid'],
['bots.workspace_uuid', 'bots.uuid'],
name='fk_bot_admins_workspace_bot',
ondelete='CASCADE',
),
)
class Bot(Base):
@@ -23,6 +42,11 @@ class Bot(Base):
__tablename__ = 'bots'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
adapter = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -38,3 +62,8 @@ class Bot(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_bots_workspace_uuid'),
sqlalchemy.Index('ix_bots_workspace_name', 'workspace_uuid', 'name'),
)
@@ -8,6 +8,11 @@ class BinaryStorage(Base):
__tablename__ = 'binary_storages'
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
primary_key=True,
)
unique_key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
owner_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -20,3 +25,12 @@ class BinaryStorage(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.Index(
'ix_binary_storages_workspace_owner',
'workspace_uuid',
'owner_type',
'owner',
),
)
+10
View File
@@ -7,6 +7,11 @@ class MCPServer(Base):
__tablename__ = 'mcp_servers'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, remote (legacy: sse, http)
@@ -22,3 +27,8 @@ class MCPServer(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.UniqueConstraint('workspace_uuid', 'name', name='uq_mcp_servers_workspace_name'),
sqlalchemy.Index('ix_mcp_servers_workspace_enable', 'workspace_uuid', 'enable'),
)
@@ -19,3 +19,17 @@ class Metadata(Base):
key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
value = sqlalchemy.Column(sqlalchemy.String(255))
class WorkspaceMetadata(Base):
"""Metadata owned by one workspace rather than by the LangBot instance."""
__tablename__ = 'workspace_metadata'
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
primary_key=True,
)
key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
value = sqlalchemy.Column(sqlalchemy.String(255))
@@ -9,6 +9,11 @@ class ModelProvider(Base):
__tablename__ = 'model_providers'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
base_url = sqlalchemy.Column(sqlalchemy.String(512), nullable=False)
@@ -21,6 +26,12 @@ class ModelProvider(Base):
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_model_providers_workspace_uuid'),
sqlalchemy.Index('ix_model_providers_workspace_name', 'workspace_uuid', 'name'),
sqlalchemy.Index('ix_model_providers_workspace_requester', 'workspace_uuid', 'requester'),
)
class LLMModel(Base):
"""LLM model"""
@@ -28,6 +39,11 @@ class LLMModel(Base):
__tablename__ = 'llm_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
@@ -42,6 +58,16 @@ class LLMModel(Base):
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.ForeignKeyConstraint(
['workspace_uuid', 'provider_uuid'],
['model_providers.workspace_uuid', 'model_providers.uuid'],
name='fk_llm_models_workspace_provider',
),
sqlalchemy.Index('ix_llm_models_workspace_provider', 'workspace_uuid', 'provider_uuid'),
sqlalchemy.Index('ix_llm_models_workspace_name', 'workspace_uuid', 'name'),
)
class EmbeddingModel(Base):
"""Embedding model"""
@@ -49,6 +75,11 @@ class EmbeddingModel(Base):
__tablename__ = 'embedding_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
@@ -61,6 +92,16 @@ class EmbeddingModel(Base):
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.ForeignKeyConstraint(
['workspace_uuid', 'provider_uuid'],
['model_providers.workspace_uuid', 'model_providers.uuid'],
name='fk_embedding_models_workspace_provider',
),
sqlalchemy.Index('ix_embedding_models_workspace_provider', 'workspace_uuid', 'provider_uuid'),
sqlalchemy.Index('ix_embedding_models_workspace_name', 'workspace_uuid', 'name'),
)
class RerankModel(Base):
"""Rerank model"""
@@ -68,6 +109,11 @@ class RerankModel(Base):
__tablename__ = 'rerank_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
@@ -79,3 +125,13 @@ class RerankModel(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.ForeignKeyConstraint(
['workspace_uuid', 'provider_uuid'],
['model_providers.workspace_uuid', 'model_providers.uuid'],
name='fk_rerank_models_workspace_provider',
),
sqlalchemy.Index('ix_rerank_models_workspace_provider', 'workspace_uuid', 'provider_uuid'),
sqlalchemy.Index('ix_rerank_models_workspace_name', 'workspace_uuid', 'name'),
)
@@ -9,6 +9,11 @@ class MonitoringMessage(Base):
__tablename__ = 'monitoring_messages'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -25,6 +30,11 @@ class MonitoringMessage(Base):
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
__table_args__ = (
sqlalchemy.Index('ix_monitoring_messages_workspace_timestamp', 'workspace_uuid', 'timestamp'),
sqlalchemy.Index('ix_monitoring_messages_workspace_session', 'workspace_uuid', 'session_id'),
)
class MonitoringLLMCall(Base):
"""LLM call records"""
@@ -32,6 +42,11 @@ class MonitoringLLMCall(Base):
__tablename__ = 'monitoring_llm_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
input_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
@@ -48,6 +63,11 @@ class MonitoringLLMCall(Base):
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
__table_args__ = (
sqlalchemy.Index('ix_monitoring_llm_calls_workspace_timestamp', 'workspace_uuid', 'timestamp'),
sqlalchemy.Index('ix_monitoring_llm_calls_workspace_session', 'workspace_uuid', 'session_id'),
)
class MonitoringToolCall(Base):
"""Tool call records"""
@@ -55,6 +75,11 @@ class MonitoringToolCall(Base):
__tablename__ = 'monitoring_tool_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
tool_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
tool_source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # native, plugin, mcp, skill
@@ -70,12 +95,22 @@ class MonitoringToolCall(Base):
result = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
__table_args__ = (
sqlalchemy.Index('ix_monitoring_tool_calls_workspace_timestamp', 'workspace_uuid', 'timestamp'),
sqlalchemy.Index('ix_monitoring_tool_calls_workspace_session', 'workspace_uuid', 'session_id'),
)
class MonitoringSession(Base):
"""Session tracking records"""
__tablename__ = 'monitoring_sessions'
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
primary_key=True,
)
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -89,6 +124,11 @@ class MonitoringSession(Base):
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
__table_args__ = (
sqlalchemy.Index('ix_monitoring_sessions_workspace_activity', 'workspace_uuid', 'last_activity'),
sqlalchemy.Index('ix_monitoring_sessions_workspace_active', 'workspace_uuid', 'is_active'),
)
class MonitoringError(Base):
"""Error log records"""
@@ -96,6 +136,11 @@ class MonitoringError(Base):
__tablename__ = 'monitoring_errors'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
error_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
@@ -107,6 +152,11 @@ class MonitoringError(Base):
stack_trace = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
__table_args__ = (
sqlalchemy.Index('ix_monitoring_errors_workspace_timestamp', 'workspace_uuid', 'timestamp'),
sqlalchemy.Index('ix_monitoring_errors_workspace_session', 'workspace_uuid', 'session_id'),
)
class MonitoringEmbeddingCall(Base):
"""Embedding call records"""
@@ -114,6 +164,11 @@ class MonitoringEmbeddingCall(Base):
__tablename__ = 'monitoring_embedding_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
prompt_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
@@ -129,6 +184,19 @@ class MonitoringEmbeddingCall(Base):
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
__table_args__ = (
sqlalchemy.Index(
'ix_monitoring_embedding_calls_workspace_timestamp',
'workspace_uuid',
'timestamp',
),
sqlalchemy.Index(
'ix_monitoring_embedding_calls_workspace_kb',
'workspace_uuid',
'knowledge_base_id',
),
)
class MonitoringFeedback(Base):
"""User feedback records (like/dislike) from AI Bot conversations"""
@@ -136,8 +204,13 @@ class MonitoringFeedback(Base):
__tablename__ = 'monitoring_feedback'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike
feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text
inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons
@@ -151,3 +224,13 @@ class MonitoringFeedback(Base):
stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom
__table_args__ = (
sqlalchemy.UniqueConstraint(
'workspace_uuid',
'feedback_id',
name='uq_monitoring_feedback_workspace_feedback_id',
),
sqlalchemy.Index('ix_monitoring_feedback_workspace_timestamp', 'workspace_uuid', 'timestamp'),
sqlalchemy.Index('ix_monitoring_feedback_workspace_session', 'workspace_uuid', 'session_id'),
)
@@ -9,6 +9,11 @@ class LegacyPipeline(Base):
__tablename__ = 'legacy_pipelines'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='⚙️')
@@ -36,6 +41,16 @@ class LegacyPipeline(Base):
},
)
__table_args__ = (
sqlalchemy.UniqueConstraint(
'workspace_uuid',
'uuid',
name='uq_legacy_pipelines_workspace_uuid',
),
sqlalchemy.Index('ix_legacy_pipelines_workspace_name', 'workspace_uuid', 'name'),
sqlalchemy.Index('ix_legacy_pipelines_workspace_default', 'workspace_uuid', 'is_default'),
)
class PipelineRunRecord(Base):
"""Pipeline run record"""
@@ -43,6 +58,11 @@ class PipelineRunRecord(Base):
__tablename__ = 'pipeline_run_records'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
status = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
@@ -56,3 +76,22 @@ class PipelineRunRecord(Base):
finished_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
result = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
knowledge_base_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
__table_args__ = (
sqlalchemy.ForeignKeyConstraint(
['workspace_uuid', 'pipeline_uuid'],
['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'],
name='fk_pipeline_run_records_workspace_pipeline',
ondelete='CASCADE',
),
sqlalchemy.Index(
'ix_pipeline_run_records_workspace_pipeline',
'workspace_uuid',
'pipeline_uuid',
),
sqlalchemy.Index(
'ix_pipeline_run_records_workspace_created',
'workspace_uuid',
'created_at',
),
)
@@ -8,6 +8,11 @@ class PluginSetting(Base):
__tablename__ = 'plugin_settings'
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
primary_key=True,
)
plugin_author = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
plugin_name = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
@@ -22,3 +27,5 @@ class PluginSetting(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (sqlalchemy.Index('ix_plugin_settings_workspace_enabled', 'workspace_uuid', 'enabled'),)
+65 -1
View File
@@ -5,6 +5,11 @@ from .base import Base
class KnowledgeBase(Base):
__tablename__ = 'knowledge_bases'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String, index=True)
description = sqlalchemy.Column(sqlalchemy.Text)
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='📚')
@@ -13,6 +18,15 @@ class KnowledgeBase(Base):
# New fields for plugin-based RAG
knowledge_engine_plugin_id = sqlalchemy.Column(sqlalchemy.String, nullable=True)
collection_id = sqlalchemy.Column(sqlalchemy.String, nullable=True)
# Server-managed compatibility marker. Pre-tenancy installations stored
# vectors directly under ``collection_id``; new knowledge bases use a
# tenant-derived opaque physical collection instead.
legacy_vector_collection = sqlalchemy.Column(
sqlalchemy.Boolean,
nullable=False,
default=False,
server_default=sqlalchemy.false(),
)
creation_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None)
retrieval_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None)
@@ -23,22 +37,72 @@ class KnowledgeBase(Base):
CREATE_FIELDS = MUTABLE_FIELDS | {'uuid', 'knowledge_engine_plugin_id', 'collection_id', 'creation_settings'}
"""Fields used when creating a new knowledge base."""
ALL_DB_FIELDS = CREATE_FIELDS | {'emoji', 'created_at', 'updated_at'}
ALL_DB_FIELDS = CREATE_FIELDS | {
'workspace_uuid',
'legacy_vector_collection',
'emoji',
'created_at',
'updated_at',
}
"""All fields stored in database (for loading from DB row)."""
__table_args__ = (
sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_knowledge_bases_workspace_uuid'),
sqlalchemy.Index('ix_knowledge_bases_workspace_name', 'workspace_uuid', 'name'),
sqlalchemy.Index(
'uq_knowledge_bases_workspace_collection',
'workspace_uuid',
'collection_id',
unique=True,
sqlite_where=sqlalchemy.text('collection_id IS NOT NULL'),
postgresql_where=sqlalchemy.text('collection_id IS NOT NULL'),
),
)
class File(Base):
__tablename__ = 'knowledge_base_files'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
kb_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
file_name = sqlalchemy.Column(sqlalchemy.String)
extension = sqlalchemy.Column(sqlalchemy.String)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())
status = sqlalchemy.Column(sqlalchemy.String, default='pending') # pending, processing, completed, failed
__table_args__ = (
sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_knowledge_base_files_workspace_uuid'),
sqlalchemy.ForeignKeyConstraint(
['workspace_uuid', 'kb_id'],
['knowledge_bases.workspace_uuid', 'knowledge_bases.uuid'],
name='fk_knowledge_base_files_workspace_kb',
ondelete='CASCADE',
),
sqlalchemy.Index('ix_knowledge_base_files_workspace_kb', 'workspace_uuid', 'kb_id'),
)
class Chunk(Base):
__tablename__ = 'knowledge_base_chunks'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
file_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
text = sqlalchemy.Column(sqlalchemy.Text)
__table_args__ = (
sqlalchemy.ForeignKeyConstraint(
['workspace_uuid', 'file_id'],
['knowledge_base_files.workspace_uuid', 'knowledge_base_files.uuid'],
name='fk_knowledge_base_chunks_workspace_file',
ondelete='CASCADE',
),
sqlalchemy.Index('ix_knowledge_base_chunks_workspace_file', 'workspace_uuid', 'file_id'),
)
@@ -1,15 +1,47 @@
import enum
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class AccountStatus(enum.StrEnum):
ACTIVE = 'active'
DISABLED = 'disabled'
DELETED = 'deleted'
class AccountSource(enum.StrEnum):
LOCAL = 'local'
CLOUD_PROJECTION = 'cloud_projection'
class User(Base):
__tablename__ = 'users'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
uuid = sqlalchemy.Column(
sqlalchemy.String(36),
nullable=False,
default=lambda: str(uuid_lib.uuid4()),
)
user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
normalized_email = sqlalchemy.Column(sqlalchemy.String(320), nullable=False)
password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
status = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=AccountStatus.ACTIVE.value,
)
source = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=AccountSource.LOCAL.value,
)
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
# Account type: 'local' (default) or 'space'
account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
@@ -27,3 +59,22 @@ class User(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.Index('uq_users_uuid', 'uuid', unique=True),
sqlalchemy.Index('uq_users_normalized_email', 'normalized_email', unique=True),
sqlalchemy.CheckConstraint(
'normalized_email = trim(normalized_email) '
'AND length(normalized_email) > 0 '
'AND length(normalized_email) <= 320',
name='ck_users_normalized_email',
),
sqlalchemy.CheckConstraint(
"status IN ('active', 'disabled', 'deleted')",
name='ck_users_status',
),
sqlalchemy.CheckConstraint(
"source IN ('local', 'cloud_projection')",
name='ck_users_source',
),
)
@@ -9,6 +9,11 @@ class Webhook(Base):
__tablename__ = 'webhooks'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
url = sqlalchemy.Column(sqlalchemy.String(1024), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')
@@ -20,3 +25,5 @@ class Webhook(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (sqlalchemy.Index('ix_webhooks_workspace_name', 'workspace_uuid', 'name'),)
@@ -0,0 +1,271 @@
from __future__ import annotations
import enum
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class WorkspaceType(enum.StrEnum):
PERSONAL = 'personal'
TEAM = 'team'
class WorkspaceStatus(enum.StrEnum):
PROVISIONING = 'provisioning'
ACTIVE = 'active'
SUSPENDED = 'suspended'
ARCHIVED = 'archived'
DELETED = 'deleted'
class WorkspaceSource(enum.StrEnum):
LOCAL = 'local'
CLOUD_PROJECTION = 'cloud_projection'
class MembershipRole(enum.StrEnum):
OWNER = 'owner'
ADMIN = 'admin'
DEVELOPER = 'developer'
OPERATOR = 'operator'
VIEWER = 'viewer'
class MembershipStatus(enum.StrEnum):
ACTIVE = 'active'
DISABLED = 'disabled'
REMOVED = 'removed'
class InvitationStatus(enum.StrEnum):
PENDING = 'pending'
ACCEPTED = 'accepted'
REVOKED = 'revoked'
EXPIRED = 'expired'
class WorkspaceExecutionStatus(enum.StrEnum):
PROVISIONING = 'provisioning'
ACTIVE = 'active'
MIGRATING = 'migrating'
DRAINING = 'draining'
INACTIVE = 'inactive'
class WorkspaceExecutionSource(enum.StrEnum):
LOCAL = 'local'
CLOUD = 'cloud'
def _new_uuid() -> str:
return str(uuid_lib.uuid4())
class Workspace(Base):
__tablename__ = 'workspaces'
uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True, default=_new_uuid)
instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
slug = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
type = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=WorkspaceType.TEAM.value,
)
status = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=WorkspaceStatus.ACTIVE.value,
)
created_by_account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='SET NULL'),
nullable=True,
)
source = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=WorkspaceSource.LOCAL.value,
)
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.UniqueConstraint('instance_uuid', 'slug', name='uq_workspaces_instance_slug'),
sqlalchemy.Index('ix_workspaces_instance_status', 'instance_uuid', 'status'),
sqlalchemy.Index(
'uq_workspaces_local_instance',
'instance_uuid',
unique=True,
sqlite_where=sqlalchemy.text("source = 'local'"),
postgresql_where=sqlalchemy.text("source = 'local'"),
),
sqlalchemy.CheckConstraint(
"type IN ('personal', 'team')",
name='ck_workspaces_type',
),
sqlalchemy.CheckConstraint(
"status IN ('provisioning', 'active', 'suspended', 'archived', 'deleted')",
name='ck_workspaces_status',
),
sqlalchemy.CheckConstraint(
"source IN ('local', 'cloud_projection')",
name='ck_workspaces_source',
),
)
class WorkspaceMembership(Base):
__tablename__ = 'workspace_memberships'
uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True, default=_new_uuid)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
role = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
status = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=MembershipStatus.ACTIVE.value,
)
invited_by_account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='SET NULL'),
nullable=True,
)
joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
sqlalchemy.CheckConstraint(
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
name='ck_workspace_memberships_role',
),
sqlalchemy.CheckConstraint(
"status IN ('active', 'disabled', 'removed')",
name='ck_workspace_memberships_status',
),
)
class WorkspaceInvitation(Base):
__tablename__ = 'workspace_invitations'
uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True, default=_new_uuid)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
)
normalized_email = sqlalchemy.Column(sqlalchemy.String(320), nullable=False)
role = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
token_hash = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
status = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=InvitationStatus.PENDING.value,
)
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
accepted_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
revoked_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
created_by_account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.Index('uq_workspace_invitations_token_hash', 'token_hash', unique=True),
sqlalchemy.Index(
'uq_workspace_invitations_pending_email',
'workspace_uuid',
'normalized_email',
unique=True,
sqlite_where=sqlalchemy.text("status = 'pending'"),
postgresql_where=sqlalchemy.text("status = 'pending'"),
),
sqlalchemy.CheckConstraint(
"role IN ('admin', 'developer', 'operator', 'viewer')",
name='ck_workspace_invitations_role',
),
sqlalchemy.CheckConstraint(
"status IN ('pending', 'accepted', 'revoked', 'expired')",
name='ck_workspace_invitations_status',
),
)
class WorkspaceExecutionState(Base):
__tablename__ = 'workspace_execution_states'
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
primary_key=True,
)
instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
active_generation = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='1')
state = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=WorkspaceExecutionStatus.ACTIVE.value,
)
write_fenced = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, server_default=sqlalchemy.false())
source = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=WorkspaceExecutionSource.LOCAL.value,
)
desired_state_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.Index('ix_workspace_execution_states_instance_state', 'instance_uuid', 'state'),
sqlalchemy.CheckConstraint('active_generation > 0', name='ck_workspace_execution_generation'),
sqlalchemy.CheckConstraint(
"state IN ('provisioning', 'active', 'migrating', 'draining', 'inactive')",
name='ck_workspace_execution_state',
),
sqlalchemy.CheckConstraint(
"source IN ('local', 'cloud')",
name='ck_workspace_execution_source',
),
)
@@ -0,0 +1,542 @@
"""add the workspace tenancy persistence kernel
Revision ID: 0009_workspace_tenancy
Revises: 0008_mcp_resource_prefs
Create Date: 2026-07-18
"""
from __future__ import annotations
import datetime
import uuid
import sqlalchemy as sa
from alembic import op
revision = '0009_workspace_tenancy'
down_revision = '0008_mcp_resource_prefs'
branch_labels = None
depends_on = None
def _table_names(conn: sa.Connection) -> set[str]:
return set(sa.inspect(conn).get_table_names())
def _column_map(conn: sa.Connection, table_name: str) -> dict[str, dict]:
return {column['name']: column for column in sa.inspect(conn).get_columns(table_name)}
def _constraint_names(conn: sa.Connection, table_name: str) -> set[str]:
inspector = sa.inspect(conn)
names = {
constraint['name']
for constraint in inspector.get_check_constraints(table_name)
if constraint.get('name') is not None
}
names.update(
constraint['name']
for constraint in inspector.get_unique_constraints(table_name)
if constraint.get('name') is not None
)
return names
def _index_names(conn: sa.Connection, table_name: str) -> set[str]:
return {index['name'] for index in sa.inspect(conn).get_indexes(table_name)}
def _upgrade_users(conn: sa.Connection) -> None:
if 'users' not in _table_names(conn):
return
columns = _column_map(conn, 'users')
if 'uuid' not in columns:
op.add_column('users', sa.Column('uuid', sa.String(36), nullable=True))
if 'status' not in columns:
op.add_column('users', sa.Column('status', sa.String(32), nullable=True, server_default='active'))
if 'source' not in columns:
op.add_column('users', sa.Column('source', sa.String(32), nullable=True, server_default='local'))
if 'projection_revision' not in columns:
op.add_column(
'users',
sa.Column('projection_revision', sa.BigInteger(), nullable=True, server_default='0'),
)
users = sa.table(
'users',
sa.column('id', sa.Integer()),
sa.column('uuid', sa.String(36)),
sa.column('status', sa.String(32)),
sa.column('source', sa.String(32)),
sa.column('projection_revision', sa.BigInteger()),
)
seen_uuids: set[str] = set()
for user_id, account_uuid in conn.execute(sa.select(users.c.id, users.c.uuid).order_by(users.c.id)).all():
normalized_uuid = account_uuid.strip() if isinstance(account_uuid, str) else ''
try:
normalized_uuid = str(uuid.UUID(normalized_uuid))
except (ValueError, AttributeError):
normalized_uuid = ''
if not normalized_uuid or normalized_uuid in seen_uuids:
normalized_uuid = str(uuid.uuid4())
if normalized_uuid != account_uuid:
conn.execute(users.update().where(users.c.id == user_id).values(uuid=normalized_uuid))
seen_uuids.add(normalized_uuid)
conn.execute(users.update().where(users.c.status.is_(None)).values(status='active'))
conn.execute(users.update().where(users.c.source.is_(None)).values(source='local'))
conn.execute(users.update().where(users.c.projection_revision.is_(None)).values(projection_revision=0))
columns = _column_map(conn, 'users')
constraint_names = _constraint_names(conn, 'users')
needs_batch_alter = any(
columns[column_name]['nullable'] for column_name in ('uuid', 'status', 'source', 'projection_revision')
) or not {'ck_users_status', 'ck_users_source'}.issubset(constraint_names)
if needs_batch_alter:
with op.batch_alter_table('users') as batch_op:
if columns['uuid']['nullable']:
batch_op.alter_column('uuid', existing_type=sa.String(36), nullable=False)
if columns['status']['nullable']:
batch_op.alter_column(
'status',
existing_type=sa.String(32),
nullable=False,
server_default='active',
)
if columns['source']['nullable']:
batch_op.alter_column(
'source',
existing_type=sa.String(32),
nullable=False,
server_default='local',
)
if columns['projection_revision']['nullable']:
batch_op.alter_column(
'projection_revision',
existing_type=sa.BigInteger(),
nullable=False,
server_default='0',
)
if 'ck_users_status' not in constraint_names:
batch_op.create_check_constraint(
'ck_users_status',
"status IN ('active', 'disabled', 'deleted')",
)
if 'ck_users_source' not in constraint_names:
batch_op.create_check_constraint(
'ck_users_source',
"source IN ('local', 'cloud_projection')",
)
if 'uq_users_uuid' not in _index_names(conn, 'users'):
op.create_index('uq_users_uuid', 'users', ['uuid'], unique=True)
def _create_workspace_tables(conn: sa.Connection) -> None:
tables = _table_names(conn)
if 'users' not in tables:
# LangBot's supported startup path creates the baseline schema before
# Alembic runs. Keep direct Alembic probes on an empty database safe.
return
if 'workspaces' not in tables:
op.create_table(
'workspaces',
sa.Column('uuid', sa.String(36), primary_key=True),
sa.Column('instance_uuid', sa.String(255), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('slug', sa.String(255), nullable=False),
sa.Column('type', sa.String(32), nullable=False, server_default='team'),
sa.Column('status', sa.String(32), nullable=False, server_default='active'),
sa.Column('created_by_account_uuid', sa.String(36), nullable=True),
sa.Column('source', sa.String(32), nullable=False, server_default='local'),
sa.Column('projection_revision', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
['created_by_account_uuid'],
['users.uuid'],
name='fk_workspaces_created_by_account',
ondelete='SET NULL',
),
sa.UniqueConstraint('instance_uuid', 'slug', name='uq_workspaces_instance_slug'),
sa.CheckConstraint("type IN ('personal', 'team')", name='ck_workspaces_type'),
sa.CheckConstraint(
"status IN ('provisioning', 'active', 'suspended', 'archived', 'deleted')",
name='ck_workspaces_status',
),
sa.CheckConstraint(
"source IN ('local', 'cloud_projection')",
name='ck_workspaces_source',
),
)
workspace_indexes = _index_names(conn, 'workspaces')
if 'ix_workspaces_instance_status' not in workspace_indexes:
op.create_index(
'ix_workspaces_instance_status',
'workspaces',
['instance_uuid', 'status'],
)
if 'uq_workspaces_local_instance' not in workspace_indexes:
op.create_index(
'uq_workspaces_local_instance',
'workspaces',
['instance_uuid'],
unique=True,
sqlite_where=sa.text("source = 'local'"),
postgresql_where=sa.text("source = 'local'"),
)
tables = _table_names(conn)
if 'workspace_memberships' not in tables:
op.create_table(
'workspace_memberships',
sa.Column('uuid', sa.String(36), primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
sa.Column('account_uuid', sa.String(36), nullable=False),
sa.Column('role', sa.String(32), nullable=False),
sa.Column('status', sa.String(32), nullable=False, server_default='active'),
sa.Column('invited_by_account_uuid', sa.String(36), nullable=True),
sa.Column('joined_at', sa.DateTime(), nullable=True),
sa.Column('projection_revision', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
['workspace_uuid'],
['workspaces.uuid'],
name='fk_workspace_memberships_workspace',
ondelete='CASCADE',
),
sa.ForeignKeyConstraint(
['account_uuid'],
['users.uuid'],
name='fk_workspace_memberships_account',
ondelete='CASCADE',
),
sa.ForeignKeyConstraint(
['invited_by_account_uuid'],
['users.uuid'],
name='fk_workspace_memberships_invited_by_account',
ondelete='SET NULL',
),
sa.UniqueConstraint(
'workspace_uuid',
'account_uuid',
name='uq_workspace_membership_account',
),
sa.CheckConstraint(
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
name='ck_workspace_memberships_role',
),
sa.CheckConstraint(
"status IN ('active', 'disabled', 'removed')",
name='ck_workspace_memberships_status',
),
)
membership_indexes = _index_names(conn, 'workspace_memberships')
if 'ix_workspace_memberships_account_status' not in membership_indexes:
op.create_index(
'ix_workspace_memberships_account_status',
'workspace_memberships',
['account_uuid', 'status'],
)
tables = _table_names(conn)
if 'workspace_invitations' not in tables:
op.create_table(
'workspace_invitations',
sa.Column('uuid', sa.String(36), primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
sa.Column('normalized_email', sa.String(320), nullable=False),
sa.Column('role', sa.String(32), nullable=False),
sa.Column('token_hash', sa.String(255), nullable=False),
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('accepted_at', sa.DateTime(), nullable=True),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('created_by_account_uuid', sa.String(36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
['workspace_uuid'],
['workspaces.uuid'],
name='fk_workspace_invitations_workspace',
ondelete='CASCADE',
),
sa.ForeignKeyConstraint(
['created_by_account_uuid'],
['users.uuid'],
name='fk_workspace_invitations_created_by_account',
ondelete='CASCADE',
),
sa.CheckConstraint(
"role IN ('admin', 'developer', 'operator', 'viewer')",
name='ck_workspace_invitations_role',
),
sa.CheckConstraint(
"status IN ('pending', 'accepted', 'revoked', 'expired')",
name='ck_workspace_invitations_status',
),
)
invitation_indexes = _index_names(conn, 'workspace_invitations')
if 'uq_workspace_invitations_token_hash' not in invitation_indexes:
op.create_index(
'uq_workspace_invitations_token_hash',
'workspace_invitations',
['token_hash'],
unique=True,
)
if 'uq_workspace_invitations_pending_email' not in invitation_indexes:
op.create_index(
'uq_workspace_invitations_pending_email',
'workspace_invitations',
['workspace_uuid', 'normalized_email'],
unique=True,
sqlite_where=sa.text("status = 'pending'"),
postgresql_where=sa.text("status = 'pending'"),
)
tables = _table_names(conn)
if 'workspace_execution_states' not in tables:
op.create_table(
'workspace_execution_states',
sa.Column('workspace_uuid', sa.String(36), primary_key=True),
sa.Column('instance_uuid', sa.String(255), nullable=False),
sa.Column('active_generation', sa.BigInteger(), nullable=False, server_default='1'),
sa.Column('state', sa.String(32), nullable=False, server_default='active'),
sa.Column('write_fenced', sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column('source', sa.String(32), nullable=False, server_default='local'),
sa.Column('desired_state_revision', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
['workspace_uuid'],
['workspaces.uuid'],
name='fk_workspace_execution_states_workspace',
ondelete='CASCADE',
),
sa.CheckConstraint('active_generation > 0', name='ck_workspace_execution_generation'),
sa.CheckConstraint(
"state IN ('provisioning', 'active', 'migrating', 'draining', 'inactive')",
name='ck_workspace_execution_state',
),
sa.CheckConstraint(
"source IN ('local', 'cloud')",
name='ck_workspace_execution_source',
),
)
execution_indexes = _index_names(conn, 'workspace_execution_states')
if 'ix_workspace_execution_states_instance_state' not in execution_indexes:
op.create_index(
'ix_workspace_execution_states_instance_state',
'workspace_execution_states',
['instance_uuid', 'state'],
)
def _load_instance_uuid(conn: sa.Connection) -> str | None:
if 'metadata' not in _table_names(conn):
return None
metadata = sa.table(
'metadata',
sa.column('key', sa.String(255)),
sa.column('value', sa.String(255)),
)
value = conn.execute(sa.select(metadata.c.value).where(metadata.c.key == 'instance_uuid')).scalar_one_or_none()
if not isinstance(value, str) or not value.strip():
return None
return value.strip()
def _bootstrap_default_workspace(conn: sa.Connection) -> None:
required_tables = {'users', 'workspaces', 'workspace_memberships', 'workspace_execution_states'}
if not required_tables.issubset(_table_names(conn)):
return
instance_uuid = _load_instance_uuid(conn)
users_exist = 'users' in _table_names(conn) and bool(conn.execute(sa.text('SELECT 1 FROM users LIMIT 1')).first())
if instance_uuid is None:
if users_exist:
raise RuntimeError("Cannot bootstrap the default workspace without metadata['instance_uuid']")
return
workspaces = sa.table(
'workspaces',
sa.column('uuid', sa.String(36)),
sa.column('instance_uuid', sa.String(255)),
sa.column('name', sa.String(255)),
sa.column('slug', sa.String(255)),
sa.column('type', sa.String(32)),
sa.column('status', sa.String(32)),
sa.column('created_by_account_uuid', sa.String(36)),
sa.column('source', sa.String(32)),
sa.column('projection_revision', sa.BigInteger()),
)
local_rows = conn.execute(
sa.select(workspaces.c.uuid).where(
workspaces.c.instance_uuid == instance_uuid,
workspaces.c.source == 'local',
)
).all()
if len(local_rows) > 1:
raise RuntimeError(f'Multiple local workspaces already exist for instance {instance_uuid!r}')
users = sa.table(
'users',
sa.column('id', sa.Integer()),
sa.column('uuid', sa.String(36)),
)
owner_account_uuid = None
if 'users' in _table_names(conn):
owner_account_uuid = conn.execute(sa.select(users.c.uuid).order_by(users.c.id).limit(1)).scalar_one_or_none()
if local_rows:
workspace_uuid = local_rows[0][0]
if owner_account_uuid is not None:
conn.execute(
workspaces.update()
.where(workspaces.c.uuid == workspace_uuid)
.where(workspaces.c.created_by_account_uuid.is_(None))
.values(created_by_account_uuid=owner_account_uuid)
)
else:
workspace_uuid = str(uuid.uuid4())
conn.execute(
workspaces.insert().values(
uuid=workspace_uuid,
instance_uuid=instance_uuid,
name='Default Workspace',
slug='default',
type='team',
status='active',
created_by_account_uuid=owner_account_uuid,
source='local',
projection_revision=0,
)
)
execution_states = sa.table(
'workspace_execution_states',
sa.column('workspace_uuid', sa.String(36)),
sa.column('instance_uuid', sa.String(255)),
sa.column('active_generation', sa.BigInteger()),
sa.column('state', sa.String(32)),
sa.column('write_fenced', sa.Boolean()),
sa.column('source', sa.String(32)),
sa.column('desired_state_revision', sa.BigInteger()),
)
execution_state = conn.execute(
sa.select(
execution_states.c.instance_uuid,
execution_states.c.active_generation,
execution_states.c.state,
execution_states.c.write_fenced,
execution_states.c.source,
).where(execution_states.c.workspace_uuid == workspace_uuid)
).first()
if execution_state is None:
conn.execute(
execution_states.insert().values(
workspace_uuid=workspace_uuid,
instance_uuid=instance_uuid,
active_generation=1,
state='active',
write_fenced=False,
source='local',
desired_state_revision=0,
)
)
elif (
execution_state.instance_uuid != instance_uuid
or execution_state.active_generation != 1
or execution_state.state != 'active'
or execution_state.write_fenced
or execution_state.source != 'local'
):
raise RuntimeError(f'Default workspace {workspace_uuid!r} has an invalid local execution state')
if owner_account_uuid is None:
return
memberships = sa.table(
'workspace_memberships',
sa.column('uuid', sa.String(36)),
sa.column('workspace_uuid', sa.String(36)),
sa.column('account_uuid', sa.String(36)),
sa.column('role', sa.String(32)),
sa.column('status', sa.String(32)),
sa.column('joined_at', sa.DateTime()),
sa.column('projection_revision', sa.BigInteger()),
)
membership = conn.execute(
sa.select(memberships.c.uuid, memberships.c.joined_at).where(
memberships.c.workspace_uuid == workspace_uuid,
memberships.c.account_uuid == owner_account_uuid,
)
).first()
now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
if membership is None:
conn.execute(
memberships.insert().values(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_uuid,
account_uuid=owner_account_uuid,
role='owner',
status='active',
joined_at=now,
projection_revision=0,
)
)
else:
conn.execute(
memberships.update()
.where(memberships.c.uuid == membership.uuid)
.values(
role='owner',
status='active',
joined_at=membership.joined_at or now,
)
)
def upgrade() -> None:
conn = op.get_bind()
_upgrade_users(conn)
_create_workspace_tables(conn)
_bootstrap_default_workspace(conn)
def downgrade() -> None:
conn = op.get_bind()
tables = _table_names(conn)
for table_name in (
'workspace_execution_states',
'workspace_invitations',
'workspace_memberships',
'workspaces',
):
if table_name in tables:
op.drop_table(table_name)
if 'users' not in _table_names(conn):
return
indexes = _index_names(conn, 'users')
if 'uq_users_uuid' in indexes:
op.drop_index('uq_users_uuid', table_name='users')
columns = _column_map(conn, 'users')
constraint_names = _constraint_names(conn, 'users')
with op.batch_alter_table('users') as batch_op:
# SQLite batch recreation otherwise preserves the named checks while
# dropping their referenced columns, producing ``no such column`` only
# after the Workspace directory tables have already been removed.
for constraint_name in ('ck_users_source', 'ck_users_status'):
if constraint_name in constraint_names:
batch_op.drop_constraint(constraint_name, type_='check')
for column_name in ('projection_revision', 'source', 'status', 'uuid'):
if column_name in columns:
batch_op.drop_column(column_name)
@@ -0,0 +1,884 @@
"""scope every tenant-owned resource to a workspace
Revision ID: 0010_scope_resources
Revises: 0009_workspace_tenancy
Create Date: 2026-07-19
This migration is intentionally expand/backfill/contract. Existing rows are
bound to the single local Workspace created by revision 0009 before any
non-null or scoped-key constraint is installed.
"""
from __future__ import annotations
import hashlib
import uuid
import sqlalchemy as sa
from alembic import op
revision = '0010_scope_resources'
down_revision = '0009_workspace_tenancy'
branch_labels = None
depends_on = None
_TENANT_TABLES = (
'api_keys',
'bots',
'bot_admins',
'binary_storages',
'mcp_servers',
'model_providers',
'llm_models',
'embedding_models',
'rerank_models',
'legacy_pipelines',
'pipeline_run_records',
'plugin_settings',
'knowledge_bases',
'knowledge_base_files',
'knowledge_base_chunks',
'webhooks',
'monitoring_messages',
'monitoring_llm_calls',
'monitoring_tool_calls',
'monitoring_sessions',
'monitoring_errors',
'monitoring_embedding_calls',
'monitoring_feedback',
)
_COMPOSITE_PRIMARY_KEYS = {
'binary_storages': ('workspace_uuid', 'unique_key'),
'plugin_settings': ('workspace_uuid', 'plugin_author', 'plugin_name'),
'monitoring_sessions': ('workspace_uuid', 'session_id'),
}
_COMPOSITE_FOREIGN_KEYS = {
'bot_admins': (
(
'fk_bot_admins_workspace_bot',
('workspace_uuid', 'bot_uuid'),
'bots',
('workspace_uuid', 'uuid'),
'CASCADE',
),
),
'llm_models': (
(
'fk_llm_models_workspace_provider',
('workspace_uuid', 'provider_uuid'),
'model_providers',
('workspace_uuid', 'uuid'),
None,
),
),
'embedding_models': (
(
'fk_embedding_models_workspace_provider',
('workspace_uuid', 'provider_uuid'),
'model_providers',
('workspace_uuid', 'uuid'),
None,
),
),
'rerank_models': (
(
'fk_rerank_models_workspace_provider',
('workspace_uuid', 'provider_uuid'),
'model_providers',
('workspace_uuid', 'uuid'),
None,
),
),
'pipeline_run_records': (
(
'fk_pipeline_run_records_workspace_pipeline',
('workspace_uuid', 'pipeline_uuid'),
'legacy_pipelines',
('workspace_uuid', 'uuid'),
'CASCADE',
),
),
'knowledge_base_files': (
(
'fk_knowledge_base_files_workspace_kb',
('workspace_uuid', 'kb_id'),
'knowledge_bases',
('workspace_uuid', 'uuid'),
'CASCADE',
),
),
'knowledge_base_chunks': (
(
'fk_knowledge_base_chunks_workspace_file',
('workspace_uuid', 'file_id'),
'knowledge_base_files',
('workspace_uuid', 'uuid'),
'CASCADE',
),
),
}
_SCOPED_INDEXES: dict[str, tuple[tuple[str, tuple[str, ...], bool, sa.TextClause | None], ...]] = {
'api_keys': (
('uq_api_keys_uuid', ('uuid',), True, None),
('uq_api_keys_key_hash', ('key_hash',), True, None),
('ix_api_keys_workspace_name', ('workspace_uuid', 'name'), False, None),
('ix_api_keys_workspace_status', ('workspace_uuid', 'status'), False, None),
),
'bots': (
('uq_bots_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
('ix_bots_workspace_name', ('workspace_uuid', 'name'), False, None),
('ix_bots_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
),
'bot_admins': (
(
'uq_bot_admin',
('workspace_uuid', 'bot_uuid', 'launcher_type', 'launcher_id'),
True,
None,
),
('ix_bot_admins_workspace_bot', ('workspace_uuid', 'bot_uuid'), False, None),
),
'binary_storages': (
(
'ix_binary_storages_workspace_owner',
('workspace_uuid', 'owner_type', 'owner'),
False,
None,
),
),
'mcp_servers': (
('uq_mcp_servers_workspace_name', ('workspace_uuid', 'name'), True, None),
('ix_mcp_servers_workspace_enable', ('workspace_uuid', 'enable'), False, None),
('ix_mcp_servers_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
),
'model_providers': (
('uq_model_providers_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
('ix_model_providers_workspace_name', ('workspace_uuid', 'name'), False, None),
('ix_model_providers_workspace_requester', ('workspace_uuid', 'requester'), False, None),
),
'llm_models': (
('ix_llm_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
('ix_llm_models_workspace_name', ('workspace_uuid', 'name'), False, None),
),
'embedding_models': (
('ix_embedding_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
('ix_embedding_models_workspace_name', ('workspace_uuid', 'name'), False, None),
),
'rerank_models': (
('ix_rerank_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
('ix_rerank_models_workspace_name', ('workspace_uuid', 'name'), False, None),
),
'legacy_pipelines': (
('uq_legacy_pipelines_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
('ix_legacy_pipelines_workspace_name', ('workspace_uuid', 'name'), False, None),
('ix_legacy_pipelines_workspace_default', ('workspace_uuid', 'is_default'), False, None),
('ix_legacy_pipelines_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
),
'pipeline_run_records': (
(
'ix_pipeline_run_records_workspace_pipeline',
('workspace_uuid', 'pipeline_uuid'),
False,
None,
),
(
'ix_pipeline_run_records_workspace_created',
('workspace_uuid', 'created_at'),
False,
None,
),
),
'plugin_settings': (('ix_plugin_settings_workspace_enabled', ('workspace_uuid', 'enabled'), False, None),),
'knowledge_bases': (
('uq_knowledge_bases_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
('ix_knowledge_bases_workspace_name', ('workspace_uuid', 'name'), False, None),
(
'uq_knowledge_bases_workspace_collection',
('workspace_uuid', 'collection_id'),
True,
sa.text('collection_id IS NOT NULL'),
),
),
'knowledge_base_files': (
('uq_knowledge_base_files_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
('ix_knowledge_base_files_workspace_kb', ('workspace_uuid', 'kb_id'), False, None),
),
'knowledge_base_chunks': (('ix_knowledge_base_chunks_workspace_file', ('workspace_uuid', 'file_id'), False, None),),
'webhooks': (
('ix_webhooks_workspace_name', ('workspace_uuid', 'name'), False, None),
('ix_webhooks_workspace_enabled', ('workspace_uuid', 'enabled'), False, None),
('ix_webhooks_workspace_created', ('workspace_uuid', 'created_at'), False, None),
),
'monitoring_messages': (
('ix_monitoring_messages_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
('ix_monitoring_messages_workspace_bot', ('workspace_uuid', 'bot_id', 'timestamp'), False, None),
(
'ix_monitoring_messages_workspace_pipeline',
('workspace_uuid', 'pipeline_id', 'timestamp'),
False,
None,
),
('ix_monitoring_messages_workspace_session', ('workspace_uuid', 'session_id'), False, None),
),
'monitoring_llm_calls': (
('ix_monitoring_llm_calls_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
('ix_monitoring_llm_calls_workspace_session', ('workspace_uuid', 'session_id'), False, None),
('ix_monitoring_llm_calls_workspace_message', ('workspace_uuid', 'message_id'), False, None),
),
'monitoring_tool_calls': (
('ix_monitoring_tool_calls_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
('ix_monitoring_tool_calls_workspace_session', ('workspace_uuid', 'session_id'), False, None),
('ix_monitoring_tool_calls_workspace_message', ('workspace_uuid', 'message_id'), False, None),
),
'monitoring_sessions': (
('ix_monitoring_sessions_workspace_activity', ('workspace_uuid', 'last_activity'), False, None),
('ix_monitoring_sessions_workspace_active', ('workspace_uuid', 'is_active'), False, None),
('ix_monitoring_sessions_workspace_bot', ('workspace_uuid', 'bot_id', 'last_activity'), False, None),
),
'monitoring_errors': (
('ix_monitoring_errors_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
('ix_monitoring_errors_workspace_session', ('workspace_uuid', 'session_id'), False, None),
('ix_monitoring_errors_workspace_message', ('workspace_uuid', 'message_id'), False, None),
),
'monitoring_embedding_calls': (
(
'ix_monitoring_embedding_calls_workspace_timestamp',
('workspace_uuid', 'timestamp'),
False,
None,
),
(
'ix_monitoring_embedding_calls_workspace_kb',
('workspace_uuid', 'knowledge_base_id'),
False,
None,
),
(
'ix_monitoring_embedding_calls_workspace_session',
('workspace_uuid', 'session_id'),
False,
None,
),
),
'monitoring_feedback': (
(
'uq_monitoring_feedback_workspace_feedback_id',
('workspace_uuid', 'feedback_id'),
True,
None,
),
('ix_monitoring_feedback_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
('ix_monitoring_feedback_workspace_session', ('workspace_uuid', 'session_id'), False, None),
('ix_monitoring_feedback_workspace_message', ('workspace_uuid', 'message_id'), False, None),
),
}
def _inspector(conn: sa.Connection) -> sa.Inspector:
return sa.inspect(conn)
def _table_names(conn: sa.Connection) -> set[str]:
return set(_inspector(conn).get_table_names())
def _columns(conn: sa.Connection, table_name: str) -> dict[str, dict]:
return {column['name']: column for column in _inspector(conn).get_columns(table_name)}
def _index_names(conn: sa.Connection, table_name: str) -> set[str]:
return {index['name'] for index in _inspector(conn).get_indexes(table_name)}
def _unique_column_sets(conn: sa.Connection, table_name: str) -> set[tuple[str, ...]]:
inspector = _inspector(conn)
result = {
tuple(constraint.get('column_names') or ()) for constraint in inspector.get_unique_constraints(table_name)
}
result.update(
tuple(index.get('column_names') or ()) for index in inspector.get_indexes(table_name) if index.get('unique')
)
return result
def _foreign_key_exists(
conn: sa.Connection,
table_name: str,
local_columns: tuple[str, ...],
referred_table: str,
referred_columns: tuple[str, ...],
) -> bool:
return any(
tuple(foreign_key.get('constrained_columns') or ()) == local_columns
and foreign_key.get('referred_table') == referred_table
and tuple(foreign_key.get('referred_columns') or ()) == referred_columns
for foreign_key in _inspector(conn).get_foreign_keys(table_name)
)
def _metadata_value(conn: sa.Connection, key: str) -> str | None:
if 'metadata' not in _table_names(conn):
return None
metadata = sa.table(
'metadata',
sa.column('key', sa.String(255)),
sa.column('value', sa.String(255)),
)
value = conn.execute(sa.select(metadata.c.value).where(metadata.c.key == key)).scalar_one_or_none()
return value.strip() if isinstance(value, str) and value.strip() else None
def _default_workspace_uuid(conn: sa.Connection) -> str | None:
if 'workspaces' not in _table_names(conn):
return None
workspaces = sa.table(
'workspaces',
sa.column('uuid', sa.String(36)),
sa.column('instance_uuid', sa.String(255)),
sa.column('source', sa.String(32)),
)
instance_uuid = _metadata_value(conn, 'instance_uuid')
query = sa.select(workspaces.c.uuid).where(workspaces.c.source == 'local')
if instance_uuid is not None:
query = query.where(workspaces.c.instance_uuid == instance_uuid)
rows = conn.execute(query).all()
if len(rows) > 1:
raise RuntimeError('Cannot backfill tenant resources: multiple local Workspaces exist')
return rows[0][0] if rows else None
def _upgrade_normalized_email(conn: sa.Connection) -> None:
if 'users' not in _table_names(conn):
return
columns = _columns(conn, 'users')
if 'normalized_email' not in columns:
op.add_column('users', sa.Column('normalized_email', sa.String(320), nullable=True))
users = sa.table(
'users',
sa.column('id', sa.Integer()),
sa.column('user', sa.String(255)),
sa.column('normalized_email', sa.String(320)),
)
# Use the exact same normalization algorithm as the runtime. Database
# ``lower()`` is ASCII-only on SQLite and is not equivalent to Python
# ``casefold()`` (for example, Straße -> strasse). Recompute every row so
# an interrupted expand/backfill attempt using an older migration body can
# be resumed safely.
seen_emails: dict[str, int] = {}
for user_id, email in conn.execute(sa.select(users.c.id, users.c.user).order_by(users.c.id)).all():
normalized_email = str(email or '').strip().casefold()
if not normalized_email:
raise RuntimeError(f'Cannot normalize empty account identity for user row {user_id}')
if len(normalized_email) > 320:
raise RuntimeError(
f'Cannot normalize account identity for user row {user_id}: canonical value exceeds 320 characters'
)
duplicate_user_id = seen_emails.get(normalized_email)
if duplicate_user_id is not None:
raise RuntimeError(
f'Cannot create normalized account identity: user rows '
f'{duplicate_user_id} and {user_id} both normalize to {normalized_email!r}'
)
seen_emails[normalized_email] = user_id
conn.execute(users.update().where(users.c.id == user_id).values(normalized_email=normalized_email))
columns = _columns(conn, 'users')
checks = {
constraint.get('name'): constraint
for constraint in _inspector(conn).get_check_constraints('users')
if constraint.get('name') is not None
}
identity_check = checks.get('ck_users_normalized_email')
identity_check_sql = str((identity_check or {}).get('sqltext') or '').casefold()
# Python casefold is the canonical identity algorithm. SQL ``lower`` is
# dialect/locale dependent (notably Cherokee folds to uppercase in Python
# but PostgreSQL lowercases it), so the database validates only portable
# structural invariants and uniqueness.
replace_legacy_identity_check = identity_check is not None and 'lower' in identity_check_sql
needs_contract = columns['normalized_email']['nullable'] or identity_check is None or replace_legacy_identity_check
if needs_contract:
with op.batch_alter_table('users') as batch_op:
if replace_legacy_identity_check:
batch_op.drop_constraint('ck_users_normalized_email', type_='check')
if columns['normalized_email']['nullable']:
batch_op.alter_column(
'normalized_email',
existing_type=columns['normalized_email']['type'],
nullable=False,
)
if identity_check is None or replace_legacy_identity_check:
batch_op.create_check_constraint(
'ck_users_normalized_email',
'normalized_email = trim(normalized_email) '
'AND length(normalized_email) > 0 '
'AND length(normalized_email) <= 320',
)
if 'uq_users_normalized_email' not in _index_names(conn, 'users'):
op.create_index('uq_users_normalized_email', 'users', ['normalized_email'], unique=True)
def _api_key_owner(conn: sa.Connection, workspace_uuid: str | None) -> str | None:
if workspace_uuid is None or 'workspace_memberships' not in _table_names(conn):
return None
memberships = sa.table(
'workspace_memberships',
sa.column('workspace_uuid', sa.String(36)),
sa.column('account_uuid', sa.String(36)),
sa.column('role', sa.String(32)),
)
return conn.execute(
sa.select(memberships.c.account_uuid)
.where(
memberships.c.workspace_uuid == workspace_uuid,
memberships.c.role == 'owner',
)
.limit(1)
).scalar_one_or_none()
def _expand_and_hash_api_keys(conn: sa.Connection, workspace_uuid: str | None) -> None:
if 'api_keys' not in _table_names(conn):
return
columns = _columns(conn, 'api_keys')
additions = (
('uuid', sa.Column('uuid', sa.String(36), nullable=True)),
('created_by_account_uuid', sa.Column('created_by_account_uuid', sa.String(36), nullable=True)),
('key_hash', sa.Column('key_hash', sa.String(64), nullable=True)),
('scopes', sa.Column('scopes', sa.JSON(), nullable=True)),
('status', sa.Column('status', sa.String(32), nullable=True, server_default='active')),
('expires_at', sa.Column('expires_at', sa.DateTime(), nullable=True)),
('last_used_at', sa.Column('last_used_at', sa.DateTime(), nullable=True)),
)
for name, column in additions:
if name not in columns:
op.add_column('api_keys', column)
columns = _columns(conn, 'api_keys')
api_key_columns = [sa.column('id', sa.Integer())]
for name in ('uuid', 'created_by_account_uuid', 'key_hash', 'scopes', 'status', 'key'):
if name in columns:
api_key_columns.append(sa.column(name, columns[name]['type']))
api_keys = sa.table('api_keys', *api_key_columns)
owner_uuid = _api_key_owner(conn, workspace_uuid)
selected_columns = [api_keys.c.id, api_keys.c.uuid, api_keys.c.key_hash]
if 'key' in api_keys.c:
selected_columns.append(api_keys.c.key)
rows = conn.execute(sa.select(*selected_columns).order_by(api_keys.c.id)).mappings().all()
for row in rows:
values: dict[str, object] = {}
if not row['uuid']:
values['uuid'] = str(uuid.uuid4())
if not row['key_hash']:
plaintext = row.get('key')
if not isinstance(plaintext, str) or not plaintext:
raise RuntimeError(f'API key row {row["id"]} has no secret to hash')
values['key_hash'] = hashlib.sha256(plaintext.encode()).hexdigest()
if values:
conn.execute(api_keys.update().where(api_keys.c.id == row['id']).values(**values))
# Pre-tenancy API keys historically had unrestricted instance access. A
# wildcard preserves that behavior while binding it to the backfilled
# Workspace; new keys must store their requested explicit scopes.
conn.execute(api_keys.update().where(api_keys.c.scopes.is_(None)).values(scopes=['*']))
conn.execute(api_keys.update().where(api_keys.c.status.is_(None)).values(status='active'))
if owner_uuid is not None:
conn.execute(
api_keys.update()
.where(api_keys.c.created_by_account_uuid.is_(None))
.values(created_by_account_uuid=owner_uuid)
)
columns = _columns(conn, 'api_keys')
check_names = {constraint.get('name') for constraint in _inspector(conn).get_check_constraints('api_keys')}
existing_fks = _inspector(conn).get_foreign_keys('api_keys')
creator_fk_exists = any(
tuple(foreign_key.get('constrained_columns') or ()) == ('created_by_account_uuid',)
and foreign_key.get('referred_table') == 'users'
and tuple(foreign_key.get('referred_columns') or ()) == ('uuid',)
for foreign_key in existing_fks
)
has_legacy_key = 'key' in columns
needs_contract = (
any(columns[name]['nullable'] for name in ('uuid', 'key_hash', 'scopes', 'status'))
or 'ck_api_keys_status' not in check_names
or not creator_fk_exists
or has_legacy_key
)
if needs_contract:
naming = {'uq': 'uq_%(table_name)s_%(column_0_name)s'}
with op.batch_alter_table('api_keys', naming_convention=naming) as batch_op:
for name in ('uuid', 'key_hash', 'scopes', 'status'):
if columns[name]['nullable']:
batch_op.alter_column(name, existing_type=columns[name]['type'], nullable=False)
if 'ck_api_keys_status' not in check_names:
batch_op.create_check_constraint('ck_api_keys_status', "status IN ('active', 'revoked')")
if not creator_fk_exists:
batch_op.create_foreign_key(
'fk_api_keys_created_by_account',
'users',
['created_by_account_uuid'],
['uuid'],
ondelete='SET NULL',
)
if has_legacy_key:
# Dropping the column also removes its old global plaintext
# unique constraint/index during SQLite's batch rebuild.
batch_op.drop_column('key')
def _expand_workspace_columns(conn: sa.Connection, workspace_uuid: str | None) -> None:
tables = _table_names(conn)
for table_name in _TENANT_TABLES:
if table_name not in tables:
continue
columns = _columns(conn, table_name)
if 'workspace_uuid' not in columns:
op.add_column(table_name, sa.Column('workspace_uuid', sa.String(36), nullable=True))
tenant_table = sa.table(table_name, sa.column('workspace_uuid', sa.String(36)))
null_count = conn.scalar(
sa.select(sa.func.count()).select_from(tenant_table).where(tenant_table.c.workspace_uuid.is_(None))
)
if null_count:
if workspace_uuid is None:
raise RuntimeError(f'Cannot backfill {table_name}: the instance has no unique local Workspace')
conn.execute(
tenant_table.update()
.where(tenant_table.c.workspace_uuid.is_(None))
.values(workspace_uuid=workspace_uuid)
)
def _mark_legacy_vector_collections(conn: sa.Connection, workspace_uuid: str | None) -> None:
"""Persist which pre-tenancy KBs must keep using ``collection_id``.
The marker is backfilled only when this migration introduces the column,
or resumes while that newly added column is still nullable. A fresh
schema already contains the non-null column with ``false`` as its default,
so knowledge bases created under the scoped-vector contract can never be
mistaken for legacy data during a later migration retry.
"""
if 'knowledge_bases' not in _table_names(conn):
return
columns = _columns(conn, 'knowledge_bases')
introduced = 'legacy_vector_collection' not in columns
if introduced:
op.add_column(
'knowledge_bases',
sa.Column('legacy_vector_collection', sa.Boolean(), nullable=True),
)
columns = _columns(conn, 'knowledge_bases')
needs_legacy_backfill = introduced or columns['legacy_vector_collection']['nullable']
knowledge_bases = sa.table(
'knowledge_bases',
sa.column('collection_id', columns['collection_id']['type']),
sa.column('legacy_vector_collection', sa.Boolean()),
*((sa.column('workspace_uuid', columns['workspace_uuid']['type']),) if 'workspace_uuid' in columns else ()),
)
if needs_legacy_backfill and workspace_uuid is not None:
legacy_filter = sa.and_(
knowledge_bases.c.collection_id.is_not(None),
sa.func.length(sa.func.trim(knowledge_bases.c.collection_id)) > 0,
)
if 'workspace_uuid' in knowledge_bases.c:
# A partially migrated database may already have Workspace
# columns. Never mark a projected cloud row as legacy.
legacy_filter = sa.and_(
legacy_filter,
sa.or_(
knowledge_bases.c.workspace_uuid.is_(None),
knowledge_bases.c.workspace_uuid == workspace_uuid,
),
)
conn.execute(knowledge_bases.update().where(legacy_filter).values(legacy_vector_collection=True))
conn.execute(
knowledge_bases.update()
.where(knowledge_bases.c.legacy_vector_collection.is_(None))
.values(legacy_vector_collection=False)
)
columns = _columns(conn, 'knowledge_bases')
if columns['legacy_vector_collection']['nullable']:
with op.batch_alter_table('knowledge_bases') as batch_op:
batch_op.alter_column(
'legacy_vector_collection',
existing_type=columns['legacy_vector_collection']['type'],
nullable=False,
server_default=sa.false(),
)
def _drop_legacy_uniqueness(conn: sa.Connection) -> None:
if 'bot_admins' in _table_names(conn):
for constraint in _inspector(conn).get_unique_constraints('bot_admins'):
if tuple(constraint.get('column_names') or ()) == ('bot_uuid', 'launcher_type', 'launcher_id'):
with op.batch_alter_table('bot_admins') as batch_op:
batch_op.drop_constraint(constraint['name'], type_='unique')
break
if 'monitoring_feedback' in _table_names(conn):
dropped_constraint = False
for constraint in _inspector(conn).get_unique_constraints('monitoring_feedback'):
if tuple(constraint.get('column_names') or ()) == ('feedback_id',):
convention = {'uq': 'uq_%(table_name)s_%(column_0_name)s'}
constraint_name = constraint.get('name') or 'uq_monitoring_feedback_feedback_id'
with op.batch_alter_table(
'monitoring_feedback',
naming_convention=convention,
) as batch_op:
batch_op.drop_constraint(constraint_name, type_='unique')
dropped_constraint = True
break
if not dropped_constraint:
for index in _inspector(conn).get_indexes('monitoring_feedback'):
if index.get('unique') and tuple(index.get('column_names') or ()) == ('feedback_id',):
op.drop_index(index['name'], table_name='monitoring_feedback')
def _create_index_if_missing(
conn: sa.Connection,
table_name: str,
name: str,
columns: tuple[str, ...],
unique: bool,
predicate: sa.TextClause | None,
) -> None:
if name in _index_names(conn, table_name):
return
if unique and predicate is None and columns in _unique_column_sets(conn, table_name):
return
kwargs = {}
if predicate is not None:
kwargs = {'sqlite_where': predicate, 'postgresql_where': predicate}
op.create_index(name, table_name, list(columns), unique=unique, **kwargs)
def _validate_scoped_unique_data(conn: sa.Connection) -> None:
checks = (
('mcp_servers', ('workspace_uuid', 'name')),
('knowledge_bases', ('workspace_uuid', 'collection_id')),
)
for table_name, column_names in checks:
if table_name not in _table_names(conn):
continue
columns = _columns(conn, table_name)
if not all(column_name in columns for column_name in column_names):
continue
table = sa.table(
table_name,
*(sa.column(column_name, columns[column_name]['type']) for column_name in column_names),
)
group_columns = [table.c[column_name] for column_name in column_names]
query = sa.select(*group_columns, sa.func.count()).group_by(*group_columns).having(sa.func.count() > 1)
if column_names[-1] == 'collection_id':
query = query.where(group_columns[-1].is_not(None))
duplicate = conn.execute(query.limit(1)).first()
if duplicate is not None:
raise RuntimeError(
f'Cannot create scoped unique key on {table_name}{column_names}: duplicate {duplicate!r}'
)
def _create_parent_and_scoped_indexes(conn: sa.Connection) -> None:
_validate_scoped_unique_data(conn)
tables = _table_names(conn)
for table_name, indexes in _SCOPED_INDEXES.items():
if table_name not in tables:
continue
available_columns = _columns(conn, table_name)
for name, columns, unique, predicate in indexes:
if all(column in available_columns for column in columns):
_create_index_if_missing(conn, table_name, name, columns, unique, predicate)
def _contract_table(conn: sa.Connection, table_name: str) -> None:
columns = _columns(conn, table_name)
if 'workspace_uuid' not in columns:
return
direct_workspace_fk = _foreign_key_exists(
conn,
table_name,
('workspace_uuid',),
'workspaces',
('uuid',),
)
current_pk = tuple(_inspector(conn).get_pk_constraint(table_name).get('constrained_columns') or ())
desired_pk = _COMPOSITE_PRIMARY_KEYS.get(table_name)
missing_composite_fks = [
foreign_key
for foreign_key in _COMPOSITE_FOREIGN_KEYS.get(table_name, ())
if not _foreign_key_exists(
conn,
table_name,
foreign_key[1],
foreign_key[2],
foreign_key[3],
)
]
needs_contract = (
columns['workspace_uuid']['nullable']
or not direct_workspace_fk
or (desired_pk is not None and current_pk != desired_pk)
or bool(missing_composite_fks)
)
if not needs_contract:
return
naming = {
'pk': 'pk_%(table_name)s',
'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s',
}
pk_name = _inspector(conn).get_pk_constraint(table_name).get('name') or f'pk_{table_name}'
with op.batch_alter_table(table_name, naming_convention=naming) as batch_op:
if columns['workspace_uuid']['nullable']:
batch_op.alter_column(
'workspace_uuid',
existing_type=columns['workspace_uuid']['type'],
nullable=False,
)
if desired_pk is not None and current_pk != desired_pk:
batch_op.drop_constraint(pk_name, type_='primary')
batch_op.create_primary_key(f'pk_{table_name}', list(desired_pk))
if not direct_workspace_fk:
batch_op.create_foreign_key(
f'fk_{table_name}_workspace',
'workspaces',
['workspace_uuid'],
['uuid'],
ondelete='CASCADE',
)
for name, local_columns, referred_table, referred_columns, ondelete in missing_composite_fks:
batch_op.create_foreign_key(
name,
referred_table,
list(local_columns),
list(referred_columns),
ondelete=ondelete,
)
def _contract_workspace_columns(conn: sa.Connection) -> None:
tables = _table_names(conn)
# Parents must be contracted before their children so SQLite can validate
# the exact composite target key during a batch-table rebuild.
order = (
'api_keys',
'bots',
'bot_admins',
'binary_storages',
'mcp_servers',
'model_providers',
'llm_models',
'embedding_models',
'rerank_models',
'legacy_pipelines',
'pipeline_run_records',
'plugin_settings',
'knowledge_bases',
'knowledge_base_files',
'knowledge_base_chunks',
'webhooks',
'monitoring_messages',
'monitoring_llm_calls',
'monitoring_tool_calls',
'monitoring_sessions',
'monitoring_errors',
'monitoring_embedding_calls',
'monitoring_feedback',
)
for table_name in order:
if table_name in tables:
_contract_table(conn, table_name)
def _migrate_workspace_metadata(conn: sa.Connection, workspace_uuid: str | None) -> None:
tables = _table_names(conn)
if 'workspaces' not in tables:
return
if 'workspace_metadata' not in tables:
op.create_table(
'workspace_metadata',
sa.Column('workspace_uuid', sa.String(36), nullable=False),
sa.Column('key', sa.String(255), nullable=False),
sa.Column('value', sa.String(255), nullable=True),
sa.ForeignKeyConstraint(
['workspace_uuid'],
['workspaces.uuid'],
name='fk_workspace_metadata_workspace',
ondelete='CASCADE',
),
sa.PrimaryKeyConstraint('workspace_uuid', 'key', name='pk_workspace_metadata'),
)
if workspace_uuid is None or 'metadata' not in tables:
return
metadata = sa.table(
'metadata',
sa.column('key', sa.String(255)),
sa.column('value', sa.String(255)),
)
workspace_metadata = sa.table(
'workspace_metadata',
sa.column('workspace_uuid', sa.String(36)),
sa.column('key', sa.String(255)),
sa.column('value', sa.String(255)),
)
tenant_keys = ('wizard_status', 'wizard_progress', 'rag_plugin_migration_needed')
rows = conn.execute(sa.select(metadata.c.key, metadata.c.value).where(metadata.c.key.in_(tenant_keys))).all()
for key, value in rows:
exists = conn.execute(
sa.select(workspace_metadata.c.key).where(
workspace_metadata.c.workspace_uuid == workspace_uuid,
workspace_metadata.c.key == key,
)
).first()
if exists is None:
conn.execute(
workspace_metadata.insert().values(
workspace_uuid=workspace_uuid,
key=key,
value=value,
)
)
if rows:
conn.execute(metadata.delete().where(metadata.c.key.in_(tenant_keys)))
def _validate_contract(conn: sa.Connection) -> None:
for table_name in _TENANT_TABLES:
if table_name not in _table_names(conn):
continue
columns = _columns(conn, table_name)
if 'workspace_uuid' not in columns or columns['workspace_uuid']['nullable']:
raise RuntimeError(f'{table_name}.workspace_uuid was not contracted to NOT NULL')
table = sa.table(table_name, sa.column('workspace_uuid', sa.String(36)))
if conn.scalar(sa.select(sa.func.count()).select_from(table).where(table.c.workspace_uuid.is_(None))):
raise RuntimeError(f'{table_name} still contains unscoped rows')
if conn.dialect.name == 'sqlite':
violations = conn.execute(sa.text('PRAGMA foreign_key_check')).all()
if violations:
raise RuntimeError(f'SQLite foreign key validation failed: {violations[:5]!r}')
def upgrade() -> None:
conn = op.get_bind()
_upgrade_normalized_email(conn)
workspace_uuid = _default_workspace_uuid(conn)
_mark_legacy_vector_collections(conn, workspace_uuid)
_expand_workspace_columns(conn, workspace_uuid)
_expand_and_hash_api_keys(conn, workspace_uuid)
_drop_legacy_uniqueness(conn)
_create_parent_and_scoped_indexes(conn)
_contract_workspace_columns(conn)
_migrate_workspace_metadata(conn, workspace_uuid)
_validate_contract(conn)
def downgrade() -> None:
raise RuntimeError(
'0010_scope_resources is intentionally irreversible because plaintext API key secrets were securely removed'
)
@@ -47,6 +47,12 @@ def _do_stamp(connection: Connection, revision: str = 'head') -> None:
command.stamp(cfg, revision)
def _do_downgrade(connection: Connection, revision: str) -> None:
"""Synchronous downgrade — runs inside run_sync."""
cfg = _build_config(connection)
command.downgrade(cfg, revision)
def _do_get_current(connection: Connection) -> str | None:
"""Get current alembic revision synchronously."""
ctx = MigrationContext.configure(connection)
@@ -73,6 +79,13 @@ async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -
await conn.commit()
async def run_alembic_downgrade(async_engine: AsyncEngine, revision: str) -> None:
"""Run Alembic downgrade to the given revision."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_downgrade, revision)
await conn.commit()
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
"""Get current alembic revision, or None if not stamped."""
async with async_engine.connect() as conn:
@@ -121,6 +134,7 @@ if __name__ == '__main__':
print('Commands:')
print(' autogenerate "message" — Generate migration from ORM model diff')
print(' upgrade [revision] — Upgrade database (default: head)')
print(' downgrade <revision> — Downgrade database to a revision')
print(' stamp [revision] — Stamp revision without running (default: head)')
print(' current — Show current revision')
sys.exit(1)
@@ -140,6 +154,13 @@ if __name__ == '__main__':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_stamp(engine, rev))
print(f'Stamped: {rev}')
elif cmd == 'downgrade':
if len(sys.argv) < 3:
print('Usage: python -m langbot.pkg.persistence.alembic_runner downgrade <revision>')
sys.exit(1)
rev = sys.argv[2]
asyncio.run(run_alembic_downgrade(engine, rev))
print(f'Downgraded to: {rev}')
elif cmd == 'current':
rev = asyncio.run(get_alembic_current(engine))
print(f'Current revision: {rev}')
+200 -7
View File
@@ -1,14 +1,16 @@
from __future__ import annotations
import datetime
import sqlite3
import typing
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy
from . import database, migration
from . import database, migration, sqlite_migration_backup
from ..entity.persistence import base, metadata, model as persistence_model
from ..entity.persistence import workspace as persistence_workspace
from ..entity import persistence
from ..core import app
from ..utils import constants, importutil
@@ -19,6 +21,51 @@ importutil.import_modules_in_pkg(migrations)
importutil.import_modules_in_pkg(persistence)
_ALEMBIC_TENANT_TABLES = {
'workspaces',
'workspace_memberships',
'workspace_invitations',
'workspace_execution_states',
'workspace_metadata',
'api_keys',
'bots',
'bot_admins',
'binary_storages',
'mcp_servers',
'model_providers',
'llm_models',
'embedding_models',
'rerank_models',
'legacy_pipelines',
'pipeline_run_records',
'plugin_settings',
'knowledge_bases',
'knowledge_base_files',
'knowledge_base_chunks',
'webhooks',
'monitoring_messages',
'monitoring_llm_calls',
'monitoring_tool_calls',
'monitoring_sessions',
'monitoring_errors',
'monitoring_embedding_calls',
'monitoring_feedback',
}
_PRE_WORKSPACE_ALEMBIC_REVISIONS = {
'0001_baseline',
'0002_sample',
'0003_add_rerank_models',
'0004_add_mcp_readme',
'0005_add_llm_context_length',
'0006_normalize_mcp_remote_mode',
'0007_add_bot_admins',
'0008_mcp_resource_prefs',
}
_WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
class PersistenceManager:
"""Persistence module manager"""
@@ -42,6 +89,8 @@ class PersistenceManager:
await self.db.initialize()
break
self._enable_sqlite_foreign_keys()
await self.create_tables()
# run migrations
@@ -79,12 +128,33 @@ class PersistenceManager:
# Run Alembic migrations (new migration system)
await self._run_alembic_migrations()
# A legacy database may not contain tenant tables introduced by a
# newer release. They were deliberately deferred before 0009 because
# their Workspace/account FK targets did not exist yet; create them
# now that the tenancy contract is in place.
await self.create_tables()
await self.write_space_model_providers()
async def create_tables(self):
# create tables
async with self.get_db_engine().connect() as conn:
await conn.run_sync(self.meta.create_all)
def create_compatible_tables(sync_conn: sqlalchemy.Connection) -> None:
inspector = sqlalchemy.inspect(sync_conn)
existing_tables = set(inspector.get_table_names())
legacy_users = 'users' in existing_tables and (
'uuid' not in {column['name'] for column in inspector.get_columns('users')}
or 'workspaces' not in existing_tables
)
# On a legacy installation, resource tables already exist
# without workspace_uuid and Workspace itself references the
# account UUID introduced by 0009. Alembic must expand those
# tables before SQLAlchemy may create any new tenant table.
excluded_tables = _ALEMBIC_TENANT_TABLES if legacy_users else set()
tables_to_create = [table for table in self.meta.sorted_tables if table.name not in excluded_tables]
self.meta.create_all(sync_conn, tables=tables_to_create)
await conn.run_sync(create_compatible_tables)
await conn.commit()
@@ -101,15 +171,80 @@ class PersistenceManager:
if row is None:
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
await self._ensure_instance_uuid_metadata()
def _enable_sqlite_foreign_keys(self) -> None:
"""Enable SQLite FK enforcement for every pooled runtime connection."""
engine = self.get_db_engine()
if engine.dialect.name != 'sqlite':
return
if getattr(self, '_sqlite_fk_listener_installed', False):
return
def set_sqlite_pragma(dbapi_connection, _connection_record) -> None:
# aiosqlite exposes the normal sqlite cursor API through its
# SQLAlchemy adapter. Guard the direct sqlite type too for tests.
if isinstance(dbapi_connection, sqlite3.Connection) or hasattr(dbapi_connection, 'cursor'):
cursor = dbapi_connection.cursor()
cursor.execute('PRAGMA foreign_keys=ON')
cursor.close()
sqlalchemy.event.listen(engine.sync_engine, 'connect', set_sqlite_pragma)
self._sqlite_fk_listener_installed = True
async def _ensure_instance_uuid_metadata(self) -> None:
"""Persist the runtime instance identifier before tenant migrations run."""
runtime_instance_uuid = constants.instance_id.strip()
if not runtime_instance_uuid:
raise RuntimeError('LangBot instance UUID is empty before persistence initialization')
result = await self.execute_async(
sqlalchemy.select(metadata.Metadata.value).where(metadata.Metadata.key == 'instance_uuid')
)
persisted_instance_uuid = result.scalar_one_or_none()
if persisted_instance_uuid is None:
await self.execute_async(
sqlalchemy.insert(metadata.Metadata).values(key='instance_uuid', value=runtime_instance_uuid)
)
return
if persisted_instance_uuid != runtime_instance_uuid:
raise RuntimeError(
'LangBot instance UUID does not match the value bound to this database: '
f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
)
async def write_space_model_providers(self):
if constants.edition != 'community':
# SaaS Workspace/provider linkage is explicit control-plane state;
# a process-level compatibility provider must never be projected
# into an arbitrary cloud Workspace.
return
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
)
# write space model providers
workspace_result = await self.execute_async(
sqlalchemy.select(persistence_workspace.Workspace.uuid).where(
persistence_workspace.Workspace.instance_uuid == constants.instance_id,
persistence_workspace.Workspace.source == persistence_workspace.WorkspaceSource.LOCAL.value,
)
)
workspace_uuids = workspace_result.scalars().all()
if len(workspace_uuids) != 1:
raise RuntimeError(
f'The fixed LangBot Models provider requires exactly one local Workspace; found {len(workspace_uuids)}'
)
workspace_uuid = workspace_uuids[0]
# The compatibility Space provider belongs to the OSS singleton
# Workspace. It must never be discovered or inserted globally.
result = await self.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.requester == 'space-chat-completions'
persistence_model.ModelProvider.workspace_uuid == workspace_uuid,
persistence_model.ModelProvider.requester == 'space-chat-completions',
)
)
exists_space_chat_completions_model_provider = result.first()
@@ -119,6 +254,7 @@ class PersistenceManager:
self.ap.logger.info('Creating space model providers...')
space_chat_completions_model_provider = {
'uuid': '00000000-0000-0000-0000-000000000000',
'workspace_uuid': workspace_uuid,
'name': 'LangBot Models',
'requester': 'space-chat-completions',
'base_url': space_models_gateway_api_url,
@@ -132,7 +268,10 @@ class PersistenceManager:
if exists_space_chat_completions_model_provider.base_url != space_models_gateway_api_url:
await self.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid)
.where(
persistence_model.ModelProvider.workspace_uuid == workspace_uuid,
persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid,
)
.values({'base_url': space_models_gateway_api_url})
)
@@ -153,13 +292,67 @@ class PersistenceManager:
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
current_rev = '0001_baseline'
# Upgrade to head
if engine.dialect.name == 'sqlite':
if current_rev in _PRE_WORKSPACE_ALEMBIC_REVISIONS:
await self._run_verified_sqlite_migration(
engine,
source_revision=current_rev,
target_revision=_WORKSPACE_ALEMBIC_REVISION,
)
current_rev = await alembic_runner.get_alembic_current(engine)
if current_rev == _WORKSPACE_ALEMBIC_REVISION:
await self._run_verified_sqlite_migration(
engine,
source_revision=current_rev,
target_revision=_RESOURCE_SCOPE_ALEMBIC_REVISION,
)
# PostgreSQL has transactional DDL. SQLite has already crossed the
# two destructive tenancy boundaries under verified backups; this
# final call is a no-op today and applies future migrations.
await alembic_runner.run_alembic_upgrade(engine, 'head')
self.ap.logger.info('Alembic migrations completed.')
except Exception as e:
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
raise
async def _run_verified_sqlite_migration(
self,
engine: sqlalchemy_asyncio.AsyncEngine,
*,
source_revision: str,
target_revision: str,
) -> None:
from . import alembic_runner
backup = await sqlite_migration_backup.create_verified_backup(
engine,
source_revision=source_revision,
target_revision=target_revision,
)
self.ap.logger.info(f'Created verified SQLite migration backup {backup.backup_path} before {target_revision}.')
try:
await alembic_runner.run_alembic_upgrade(engine, target_revision)
completed_revision = await alembic_runner.get_alembic_current(engine)
if completed_revision != target_revision:
raise RuntimeError(f'Alembic stopped at {completed_revision!r}, expected {target_revision!r}')
await sqlite_migration_backup.mark_migration_succeeded(
backup,
completed_revision=completed_revision,
)
except BaseException:
await sqlite_migration_backup.restore_verified_backup(engine, backup)
restored_revision = await alembic_runner.get_alembic_current(engine)
if restored_revision != source_revision:
raise RuntimeError(
f'SQLite migration recovery restored revision {restored_revision!r}, expected {source_revision!r}'
)
self.ap.logger.error(
f'SQLite migration to {target_revision} failed; restored verified backup '
f'{backup.backup_path} at revision {source_revision}.'
)
raise
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
async with self.get_db_engine().connect() as conn:
result = await conn.execute(*args, **kwargs)
@@ -0,0 +1,272 @@
"""Durable SQLite backups for destructive Alembic migration boundaries."""
from __future__ import annotations
import asyncio
import dataclasses
import datetime
import json
import os
import pathlib
import re
import secrets
import sqlite3
import tempfile
import typing
from sqlalchemy.ext.asyncio import AsyncEngine
class SQLiteMigrationBackupError(RuntimeError):
"""A verified migration backup could not be created or restored."""
@dataclasses.dataclass(frozen=True, slots=True)
class SQLiteMigrationBackup:
database_path: pathlib.Path
backup_path: pathlib.Path
manifest_path: pathlib.Path
source_revision: str
target_revision: str
created_at: str
def _safe_label(value: str) -> str:
label = re.sub(r'[^A-Za-z0-9_.-]+', '-', value).strip('-')
return label or 'unknown'
def _database_path(engine: AsyncEngine) -> pathlib.Path:
if engine.dialect.name != 'sqlite':
raise SQLiteMigrationBackupError('SQLite migration backups require a SQLite engine')
database = engine.url.database
if not database or database == ':memory:' or engine.url.query.get('mode') == 'memory':
raise SQLiteMigrationBackupError('Tenant schema migrations require a file-backed SQLite database for recovery')
database_path = pathlib.Path(database).expanduser()
if not database_path.is_absolute():
database_path = pathlib.Path.cwd() / database_path
database_path = database_path.resolve()
if not database_path.is_file():
raise SQLiteMigrationBackupError(f'SQLite database does not exist: {database_path}')
return database_path
def _open_read_only(path: pathlib.Path) -> sqlite3.Connection:
return sqlite3.connect(f'{path.as_uri()}?mode=ro', uri=True, timeout=30)
def _read_revision(connection: sqlite3.Connection) -> str | None:
has_version_table = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'alembic_version'"
).fetchone()
if has_version_table is None:
return None
rows = connection.execute('SELECT version_num FROM alembic_version').fetchall()
if not rows:
return None
if len(rows) != 1 or not isinstance(rows[0][0], str):
raise SQLiteMigrationBackupError('SQLite backup has an invalid Alembic revision table')
return rows[0][0]
def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -> None:
quick_check = connection.execute('PRAGMA quick_check').fetchall()
if quick_check != [('ok',)]:
raise SQLiteMigrationBackupError(f'SQLite quick_check failed: {quick_check[:5]!r}')
actual_revision = _read_revision(connection)
if actual_revision != expected_revision:
raise SQLiteMigrationBackupError(
f'SQLite backup revision mismatch: {actual_revision!r} != {expected_revision!r}'
)
def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
with _open_read_only(path) as connection:
_verify_connection(connection, expected_revision)
def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.Any) -> None:
payload: dict[str, typing.Any] = {
'version': 1,
'status': status,
'created_at': backup.created_at,
'database_path': str(backup.database_path),
'backup_path': str(backup.backup_path),
'source_revision': backup.source_revision,
'target_revision': backup.target_revision,
'quick_check': 'ok',
**extra,
}
backup.manifest_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f'.{backup.manifest_path.name}.',
suffix='.tmp',
dir=backup.manifest_path.parent,
)
temporary_path = pathlib.Path(temporary_name)
try:
with os.fdopen(descriptor, 'w', encoding='utf-8') as file:
json.dump(payload, file, ensure_ascii=False, indent=2, sort_keys=True)
file.write('\n')
file.flush()
os.fsync(file.fileno())
os.chmod(temporary_path, 0o600)
os.replace(temporary_path, backup.manifest_path)
_fsync_directory(backup.manifest_path.parent)
finally:
temporary_path.unlink(missing_ok=True)
def _fsync_file(path: pathlib.Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _fsync_directory(path: pathlib.Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _create_backup(
database_path: pathlib.Path,
source_revision: str,
target_revision: str,
) -> SQLiteMigrationBackup:
backup_directory = database_path.parent / 'migration-backups'
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(backup_directory, 0o700)
created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
stem = (
f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
f'from-{_safe_label(source_revision)}-{created_at}-{secrets.token_hex(4)}'
)
backup_path = backup_directory / f'{stem}.sqlite3'
manifest_path = backup_directory / f'{stem}.json'
descriptor, temporary_name = tempfile.mkstemp(
prefix=f'.{stem}.',
suffix='.creating',
dir=backup_directory,
)
os.close(descriptor)
temporary_path = pathlib.Path(temporary_name)
try:
with (
_open_read_only(database_path) as source,
sqlite3.connect(
temporary_path,
timeout=30,
) as destination,
):
source.execute('PRAGMA busy_timeout = 30000')
source.backup(destination)
destination.commit()
_verify_connection(destination, source_revision)
os.chmod(temporary_path, 0o600)
_fsync_file(temporary_path)
os.replace(temporary_path, backup_path)
_fsync_file(backup_path)
_fsync_directory(backup_directory)
backup = SQLiteMigrationBackup(
database_path=database_path,
backup_path=backup_path,
manifest_path=manifest_path,
source_revision=source_revision,
target_revision=target_revision,
created_at=created_at,
)
_write_manifest(backup, 'verified')
return backup
except Exception:
backup_path.unlink(missing_ok=True)
manifest_path.unlink(missing_ok=True)
raise
finally:
temporary_path.unlink(missing_ok=True)
async def create_verified_backup(
engine: AsyncEngine,
*,
source_revision: str,
target_revision: str,
) -> SQLiteMigrationBackup:
"""Create and verify an online-consistent backup next to instance data."""
database_path = _database_path(engine)
return await asyncio.to_thread(
_create_backup,
database_path,
source_revision,
target_revision,
)
def _restore_backup(backup: SQLiteMigrationBackup) -> None:
_verify_file(backup.backup_path, backup.source_revision)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f'.{backup.database_path.name}.',
suffix='.restoring',
dir=backup.database_path.parent,
)
os.close(descriptor)
temporary_path = pathlib.Path(temporary_name)
try:
with (
_open_read_only(backup.backup_path) as source,
sqlite3.connect(
temporary_path,
timeout=30,
) as destination,
):
source.backup(destination)
destination.commit()
_verify_connection(destination, backup.source_revision)
os.chmod(temporary_path, 0o600)
_fsync_file(temporary_path)
# A stale WAL could replay pages from the failed migration after the
# main database file is replaced. The engine is disposed before this
# function runs, so these exact sidecars are safe to remove.
for suffix in ('-wal', '-shm', '-journal'):
pathlib.Path(f'{backup.database_path}{suffix}').unlink(missing_ok=True)
os.replace(temporary_path, backup.database_path)
_fsync_file(backup.database_path)
_fsync_directory(backup.database_path.parent)
_verify_file(backup.database_path, backup.source_revision)
finally:
temporary_path.unlink(missing_ok=True)
async def restore_verified_backup(engine: AsyncEngine, backup: SQLiteMigrationBackup) -> None:
"""Atomically restore a verified backup after a migration failure."""
await engine.dispose()
await asyncio.to_thread(_restore_backup, backup)
await asyncio.to_thread(
_write_manifest,
backup,
'restored_after_failure',
restored_at=datetime.datetime.now(datetime.UTC).isoformat(),
)
async def mark_migration_succeeded(
backup: SQLiteMigrationBackup,
*,
completed_revision: str,
) -> None:
"""Mark a retained verified backup after its migration boundary succeeds."""
await asyncio.to_thread(
_write_manifest,
backup,
'migration_succeeded',
completed_at=datetime.datetime.now(datetime.UTC).isoformat(),
completed_revision=completed_revision,
)
+172 -138
View File
@@ -1,10 +1,4 @@
"""Message Aggregator Module
This module provides message aggregation/debounce functionality.
When users send multiple messages consecutively, the aggregator will wait
for a configurable delay period and merge them into a single message
before processing.
"""
"""Workspace-scoped message aggregation and debounce support."""
from __future__ import annotations
@@ -13,96 +7,114 @@ import time
import typing
from dataclasses import dataclass, field
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from ..api.http.context import ExecutionContext
from .pool import ExecutionContextMismatchError
from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
if typing.TYPE_CHECKING:
from ..core import app
# Maximum number of messages to buffer before forcing a flush
MAX_BUFFER_MESSAGES = 10
AggregationKey = tuple[
str,
str,
int,
str,
str | None,
str,
int | str,
]
@dataclass
class PendingMessage:
"""A pending message waiting to be aggregated"""
"""A pending message carrying its trusted execution scope."""
execution_context: ExecutionContext
bot_uuid: str
launcher_type: provider_session.LauncherTypes
launcher_id: typing.Union[int, str]
sender_id: typing.Union[int, str]
launcher_id: int | str
sender_id: int | str
message_event: platform_events.MessageEvent
message_chain: platform_message.MessageChain
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
pipeline_uuid: typing.Optional[str]
pipeline_uuid: str | None
routed_by_rule: bool = False
timestamp: float = field(default_factory=time.time)
@dataclass
class SessionBuffer:
"""Buffer for a single session's pending messages"""
"""Pending messages for one scoped aggregation key."""
session_id: str
aggregation_key: AggregationKey
execution_context: ExecutionContext
messages: list[PendingMessage] = field(default_factory=list)
timer_task: typing.Optional[asyncio.Task] = None
timer_task: asyncio.Task | None = None
last_message_time: float = field(default_factory=time.time)
class MessageAggregator:
"""Message aggregator that buffers and merges consecutive messages
This class implements a debounce mechanism for incoming messages.
When a message arrives, it starts a timer. If more messages arrive
before the timer expires, they are buffered. When the timer expires,
all buffered messages are merged and sent to the query pool.
"""
"""Debounce consecutive messages without crossing Workspace boundaries."""
ap: app.Application
buffers: dict[str, SessionBuffer]
"""Session ID -> SessionBuffer mapping"""
buffers: dict[AggregationKey, SessionBuffer]
lock: asyncio.Lock
"""Lock for thread-safe buffer operations"""
def __init__(self, ap: app.Application):
self.ap = ap
self.buffers = {}
self.lock = asyncio.Lock()
def _get_session_id(
def _get_aggregation_key(
self,
execution_context: ExecutionContext,
bot_uuid: str,
launcher_type: provider_session.LauncherTypes,
launcher_id: typing.Union[int, str],
) -> str:
"""Generate a unique session ID"""
return f'{bot_uuid}:{launcher_type.value}:{launcher_id}'
launcher_id: int | str,
pipeline_uuid: str | None,
) -> AggregationKey:
"""Build a key that cannot alias another Workspace, bot, or pipeline."""
async def _get_aggregation_config(self, pipeline_uuid: typing.Optional[str]) -> tuple[bool, float]:
"""Get aggregation configuration for a pipeline
return (
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
bot_uuid,
pipeline_uuid,
launcher_type.value,
launcher_id,
)
async def _get_aggregation_config(
self,
execution_context: ExecutionContext,
pipeline_uuid: str | None,
) -> tuple[bool, float]:
"""Return aggregation enablement and a clamped debounce delay."""
Returns:
tuple: (enabled, delay_seconds)
"""
default_enabled = False
default_delay = 1.5
if pipeline_uuid is None:
return default_enabled, default_delay
# Get pipeline from pipeline manager
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(
execution_context,
pipeline_uuid,
)
if pipeline is None:
return default_enabled, default_delay
config = pipeline.pipeline_entity.config or {}
trigger_config = config.get('trigger', {})
aggregation_config = trigger_config.get('message-aggregation', {})
enabled = aggregation_config.get('enabled', default_enabled)
delay_raw = aggregation_config.get('delay', default_delay)
@@ -111,33 +123,31 @@ class MessageAggregator:
except (TypeError, ValueError):
delay = default_delay
# Clamp delay to valid range
delay = max(1.0, min(10.0, delay))
return enabled, delay
return enabled, max(1.0, min(10.0, delay))
async def add_message(
self,
bot_uuid: str,
launcher_type: provider_session.LauncherTypes,
launcher_id: typing.Union[int, str],
sender_id: typing.Union[int, str],
launcher_id: int | str,
sender_id: int | str,
message_event: platform_events.MessageEvent,
message_chain: platform_message.MessageChain,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
pipeline_uuid: str | None = None,
routed_by_rule: bool = False,
execution_context: ExecutionContext | None = None,
) -> None:
"""Add a message to the aggregation buffer
"""Buffer or directly enqueue a message in its trusted Workspace."""
If aggregation is disabled for the pipeline, the message is sent
directly to the query pool. Otherwise, it's buffered and will be
merged with other messages from the same session.
"""
enabled, delay = await self._get_aggregation_config(pipeline_uuid)
execution_context = await self.ap.query_pool.resolve_execution_context(
execution_context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
)
enabled, delay = await self._get_aggregation_config(execution_context, pipeline_uuid)
if not enabled:
# Aggregation disabled, send directly to query pool
await self.ap.query_pool.add_query(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
@@ -148,12 +158,19 @@ class MessageAggregator:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
execution_context=execution_context,
)
return
session_id = self._get_session_id(bot_uuid, launcher_type, launcher_id)
aggregation_key = self._get_aggregation_key(
execution_context,
bot_uuid,
launcher_type,
launcher_id,
pipeline_uuid,
)
pending_msg = PendingMessage(
execution_context=execution_context,
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
@@ -167,106 +184,122 @@ class MessageAggregator:
force_flush = False
async with self.lock:
if session_id in self.buffers:
buffer = self.buffers[session_id]
# Cancel existing timer (just cancel, don't await inside lock)
buffer = self.buffers.get(aggregation_key)
if buffer is None:
buffer = SessionBuffer(
aggregation_key=aggregation_key,
execution_context=execution_context,
messages=[pending_msg],
)
self.buffers[aggregation_key] = buffer
else:
if buffer.execution_context != execution_context:
raise ExecutionContextMismatchError('Aggregation buffer ExecutionContext changed for the same key')
if buffer.timer_task and not buffer.timer_task.done():
buffer.timer_task.cancel()
buffer.messages.append(pending_msg)
else:
buffer = SessionBuffer(
session_id=session_id,
messages=[pending_msg],
)
self.buffers[session_id] = buffer
buffer.last_message_time = time.time()
# Check if buffer reached max capacity
if len(buffer.messages) >= MAX_BUFFER_MESSAGES:
force_flush = True
else:
# Start new timer
buffer.timer_task = asyncio.create_task(self._delayed_flush(session_id, delay))
buffer.timer_task = asyncio.create_task(self._delayed_flush(aggregation_key, delay, execution_context))
if force_flush:
await self._flush_buffer(session_id)
await self._flush_buffer(aggregation_key, execution_context)
async def _delayed_flush(
self,
aggregation_key: AggregationKey,
delay: float,
execution_context: ExecutionContext,
) -> None:
"""Flush after the debounce delay using the captured context."""
async def _delayed_flush(self, session_id: str, delay: float) -> None:
"""Wait for delay then flush the buffer"""
try:
await asyncio.sleep(delay)
await self._flush_buffer(session_id)
await self._flush_buffer(aggregation_key, execution_context)
except asyncio.CancelledError:
# Timer was cancelled, new message arrived
pass
async def _flush_buffer(self, session_id: str) -> None:
"""Flush the buffer for a session, merging all messages"""
async with self.lock:
buffer = self.buffers.pop(session_id, None)
if buffer is None or not buffer.messages:
return
if len(buffer.messages) == 1:
# Only one message, no need to merge
msg = buffer.messages[0]
await self.ap.query_pool.add_query(
bot_uuid=msg.bot_uuid,
launcher_type=msg.launcher_type,
launcher_id=msg.launcher_id,
sender_id=msg.sender_id,
message_event=msg.message_event,
message_chain=msg.message_chain,
adapter=msg.adapter,
pipeline_uuid=msg.pipeline_uuid,
routed_by_rule=msg.routed_by_rule,
except WorkspaceError as exc:
self.ap.logger.info(
f'Dropped an aggregated message because its Workspace execution binding is stale: {exc}'
)
async def _flush_buffer(
self,
aggregation_key: AggregationKey,
execution_context: ExecutionContext,
) -> None:
"""Flush one buffer only when the captured scope still matches."""
async with self.lock:
buffer = self.buffers.get(aggregation_key)
if buffer is None:
return
if buffer.execution_context != execution_context:
raise ExecutionContextMismatchError('Timer ExecutionContext does not match the aggregation buffer')
self.buffers.pop(aggregation_key)
if not buffer.messages:
return
# Merge multiple messages
merged_msg = self._merge_messages(buffer.messages)
message = buffer.messages[0] if len(buffer.messages) == 1 else self._merge_messages(buffer.messages)
binding = await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
if binding.instance_uuid != execution_context.instance_uuid:
raise WorkspaceInvariantError('Aggregation buffer instance does not match the active Workspace binding')
await self.ap.query_pool.add_query(
bot_uuid=merged_msg.bot_uuid,
launcher_type=merged_msg.launcher_type,
launcher_id=merged_msg.launcher_id,
sender_id=merged_msg.sender_id,
message_event=merged_msg.message_event,
message_chain=merged_msg.message_chain,
adapter=merged_msg.adapter,
pipeline_uuid=merged_msg.pipeline_uuid,
routed_by_rule=merged_msg.routed_by_rule,
bot_uuid=message.bot_uuid,
launcher_type=message.launcher_type,
launcher_id=message.launcher_id,
sender_id=message.sender_id,
message_event=message.message_event,
message_chain=message.message_chain,
adapter=message.adapter,
pipeline_uuid=message.pipeline_uuid,
routed_by_rule=message.routed_by_rule,
execution_context=message.execution_context,
)
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
"""Merge multiple messages into one
"""Merge message chains after proving all messages share one scope."""
The merged message uses the first message as base and combines
all message chains with newline separators.
The original message_event is kept unmodified to preserve
message metadata (message_id, etc.) for reply/quote.
"""
if not messages:
raise ValueError('At least one pending message is required')
if len(messages) == 1:
return messages[0]
base_msg = messages[0]
base_key = self._get_aggregation_key(
base_msg.execution_context,
base_msg.bot_uuid,
base_msg.launcher_type,
base_msg.launcher_id,
base_msg.pipeline_uuid,
)
for message in messages[1:]:
message_key = self._get_aggregation_key(
message.execution_context,
message.bot_uuid,
message.launcher_type,
message.launcher_id,
message.pipeline_uuid,
)
if message_key != base_key or message.execution_context != base_msg.execution_context:
raise ExecutionContextMismatchError('Cannot merge pending messages from different execution scopes')
# Build merged message chain
merged_chain = platform_message.MessageChain([])
for i, msg in enumerate(messages):
if i > 0:
# Add newline separator between messages
for index, message in enumerate(messages):
if index > 0:
merged_chain.append(platform_message.Plain(text='\n'))
# Copy all components from this message
for component in msg.message_chain:
for component in message.message_chain:
merged_chain.append(component)
# Keep message_event unmodified (preserves original message_id and
# metadata for reply/quote), only pass merged chain separately
return PendingMessage(
execution_context=base_msg.execution_context,
bot_uuid=base_msg.bot_uuid,
launcher_type=base_msg.launcher_type,
launcher_id=base_msg.launcher_id,
@@ -275,22 +308,23 @@ class MessageAggregator:
message_chain=merged_chain,
adapter=base_msg.adapter,
pipeline_uuid=base_msg.pipeline_uuid,
routed_by_rule=any(msg.routed_by_rule for msg in messages),
routed_by_rule=any(message.routed_by_rule for message in messages),
)
async def flush_all(self) -> None:
"""Flush all pending buffers immediately
"""Flush all pending buffers without dropping their captured scopes."""
This is useful during shutdown to ensure no messages are lost.
"""
# Snapshot session IDs and cancel all timers under lock
async with self.lock:
session_ids = list(self.buffers.keys())
for sid in session_ids:
buffer = self.buffers.get(sid)
if buffer and buffer.timer_task and not buffer.timer_task.done():
pending_buffers = [(key, buffer.execution_context) for key, buffer in self.buffers.items()]
for buffer in self.buffers.values():
if buffer.timer_task and not buffer.timer_task.done():
buffer.timer_task.cancel()
# Flush each buffer outside the lock
for session_id in session_ids:
await self._flush_buffer(session_id)
for aggregation_key, execution_context in pending_buffers:
try:
await self._flush_buffer(aggregation_key, execution_context)
except WorkspaceError as exc:
self.ap.logger.info(
'Dropped an aggregated message during shutdown because its '
f'Workspace execution binding is stale: {exc}'
)
+53 -27
View File
@@ -5,8 +5,10 @@ import traceback
from ..core import app
from ..core import entities as core_entities
from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from .pool import get_query_execution_context
class Controller:
@@ -21,6 +23,52 @@ class Controller:
self.ap = ap
self.semaphore = asyncio.Semaphore(self.ap.instance_config.data['concurrency']['pipeline'])
async def _assert_query_execution_active(
self,
query: pipeline_query.Query,
):
"""Revalidate a queued query immediately before runtime work starts."""
execution_context = get_query_execution_context(query)
binding = await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
if binding.instance_uuid != execution_context.instance_uuid:
raise WorkspaceInvariantError('Queued query instance does not match the active Workspace binding')
return execution_context
async def _process_query(self, selected_query: pipeline_query.Query) -> None:
"""Run one selected query and always release its scheduling slot."""
try:
async with self.semaphore:
execution_context = await self._assert_query_execution_active(selected_query)
pipeline_uuid = selected_query.pipeline_uuid
if pipeline_uuid:
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(
execution_context,
pipeline_uuid,
)
if pipeline:
await pipeline.run(selected_query)
else:
self.ap.logger.warning(
f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
)
else:
self.ap.logger.warning(f'No pipeline_uuid for query {selected_query.query_id}, query dropped')
except WorkspaceError as exc:
self.ap.logger.info(
f'Dropped query {selected_query.query_id} because its Workspace execution binding is stale: {exc}'
)
finally:
await self.ap.query_pool.remove_query(selected_query)
async with self.ap.query_pool:
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
self.ap.query_pool.condition.notify_all()
async def consumer(self):
"""事件处理循环"""
try:
@@ -51,40 +99,18 @@ class Controller:
continue
if selected_query:
async def _process_query(selected_query: pipeline_query.Query):
async with self.semaphore: # 总并发上限
# find pipeline
# Here firstly find the bot, then find the pipeline, in case the bot adapter's config is not the latest one.
# Like aiocqhttp, once a client is connected, even the adapter was updated and restarted, the existing client connection will not be affected.
pipeline_uuid = selected_query.pipeline_uuid
if pipeline_uuid:
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
if pipeline:
await pipeline.run(selected_query)
else:
self.ap.logger.warning(
f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
)
else:
self.ap.logger.warning(
f'No pipeline_uuid for query {selected_query.query_id}, query dropped'
)
async with self.ap.query_pool:
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
# 通知其他协程,有新的请求可以处理了
self.ap.query_pool.condition.notify_all()
execution_context = get_query_execution_context(selected_query)
self.ap.task_mgr.create_task(
_process_query(selected_query),
self._process_query(selected_query),
kind='query',
name=f'query-{selected_query.query_id}',
scopes=[
core_entities.LifecycleControlScope.APPLICATION,
core_entities.LifecycleControlScope.PLATFORM,
],
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
)
except Exception as e:
@@ -15,6 +15,8 @@ if typing.TYPE_CHECKING:
from ..core import app
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from .pool import get_query_execution_context
class MonitoringHelper:
"""Helper class for monitoring operations"""
@@ -54,6 +56,7 @@ class MonitoringHelper:
# Here we just record None, the full variables will be set when query completes
message_id = await ap.monitoring_service.record_message(
get_query_execution_context(query),
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
@@ -74,6 +77,7 @@ class MonitoringHelper:
# Update session activity or create new session if it doesn't exist
# Always pass pipeline info to handle pipeline switches
session_updated = await ap.monitoring_service.update_session_activity(
get_query_execution_context(query),
session_id,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
@@ -81,6 +85,7 @@ class MonitoringHelper:
if not session_updated:
# Session doesn't exist, create it
await ap.monitoring_service.record_session_start(
get_query_execution_context(query),
session_id=session_id,
bot_id=bot_id,
bot_name=bot_name,
@@ -118,6 +123,7 @@ class MonitoringHelper:
pass
await ap.monitoring_service.update_message_status(
get_query_execution_context(query),
message_id=message_id,
status='success',
variables=query_variables_str,
@@ -170,6 +176,7 @@ class MonitoringHelper:
return # No response to record
await ap.monitoring_service.record_message(
get_query_execution_context(query),
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
@@ -215,6 +222,7 @@ class MonitoringHelper:
# Record error message
message_id = await ap.monitoring_service.record_message(
get_query_execution_context(query),
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
@@ -233,6 +241,7 @@ class MonitoringHelper:
# Record error log
await ap.monitoring_service.record_error(
get_query_execution_context(query),
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
@@ -271,6 +280,7 @@ class MonitoringHelper:
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
await ap.monitoring_service.record_llm_call(
get_query_execution_context(query),
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
+152 -10
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import dataclasses
import typing
import traceback
@@ -13,7 +14,11 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.events as events
from ..utils import importutil
from ..api.http.authz import WorkspaceRequiredError
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
from .config_coercion import coerce_pipeline_config
from .pool import get_query_execution_context
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -82,15 +87,39 @@ class RuntimePipeline:
enable_all_mcp_servers: bool
"""是否启用所有MCP服务器"""
execution_context: ExecutionContext
workspace_uuid: str
placement_generation: int
def __init__(
self,
ap: app.Application,
pipeline_entity: persistence_pipeline.LegacyPipeline,
stage_containers: list[StageInstContainer],
execution_context: ExecutionContext,
):
if not isinstance(execution_context, ExecutionContext):
raise WorkspaceRequiredError('RuntimePipeline requires an ExecutionContext')
if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
raise WorkspaceRequiredError('RuntimePipeline requires an instance and Workspace')
if execution_context.placement_generation <= 0:
raise WorkspaceRequiredError('RuntimePipeline requires a positive placement generation')
if pipeline_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceRequiredError('RuntimePipeline entity Workspace does not match its ExecutionContext')
if execution_context.pipeline_uuid not in (None, pipeline_entity.uuid):
raise WorkspaceRequiredError('RuntimePipeline UUID does not match its ExecutionContext')
self.ap = ap
self.pipeline_entity = pipeline_entity
self.stage_containers = stage_containers
self.execution_context = dataclasses.replace(
execution_context,
pipeline_uuid=pipeline_entity.uuid,
)
self.workspace_uuid = self.execution_context.workspace_uuid
self.placement_generation = self.execution_context.placement_generation
# Extract bound plugins and MCP servers from extensions_preferences
extensions_prefs = pipeline_entity.extensions_preferences or {}
@@ -120,7 +149,37 @@ class RuntimePipeline:
mcp_server_list = extensions_prefs.get('mcp_servers', [])
self.bound_mcp_servers = mcp_server_list if mcp_server_list else []
async def _assert_execution_active(
self,
query: pipeline_query.Query | None = None,
) -> ExecutionContext:
"""Fail closed when this runtime or query belongs to a stale placement."""
execution_context = self.execution_context if query is None else get_query_execution_context(query)
if (
execution_context.instance_uuid != self.execution_context.instance_uuid
or execution_context.workspace_uuid != self.workspace_uuid
or execution_context.placement_generation != self.placement_generation
or execution_context.pipeline_uuid != self.pipeline_entity.uuid
):
raise WorkspaceInvariantError('Query execution scope does not match RuntimePipeline')
binding = await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
if binding.instance_uuid != execution_context.instance_uuid:
raise WorkspaceInvariantError('RuntimePipeline instance does not match the active Workspace binding')
return execution_context
async def run(self, query: pipeline_query.Query):
if (
query.instance_uuid != self.execution_context.instance_uuid
or query.workspace_uuid != self.workspace_uuid
or query.placement_generation != self.placement_generation
or query.pipeline_uuid != self.pipeline_entity.uuid
):
raise WorkspaceRequiredError('Query execution scope does not match RuntimePipeline')
await self._assert_execution_active(query)
query.pipeline_config = self.pipeline_entity.config
# Store bound plugins and MCP servers in query for filtering
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
@@ -134,7 +193,11 @@ class RuntimePipeline:
bot_name = 'WebChat'
if query.bot_uuid:
try:
bot = await self.ap.bot_service.get_bot(query.bot_uuid, include_secret=False)
bot = await self.ap.bot_service.get_bot(
query.workspace_uuid,
query.bot_uuid,
include_secret=False,
)
if bot:
bot_name = bot.get('name', 'Unknown')
except Exception:
@@ -150,6 +213,7 @@ class RuntimePipeline:
async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult):
"""检查输出"""
await self._assert_execution_active(query)
if result.user_notice:
# 处理str类型
@@ -162,7 +226,9 @@ class RuntimePipeline:
query.message_event, platform_events.GroupMessage
):
result.user_notice.insert(0, platform_message.At(target=query.message_event.sender.id))
if await query.adapter.is_stream_output_supported() and query.resp_messages:
stream_output_supported = await query.adapter.is_stream_output_supported()
await self._assert_execution_active(query)
if stream_output_supported and query.resp_messages:
await query.adapter.reply_message_chunk(
message_source=query.message_event,
bot_message=query.resp_messages[-1],
@@ -186,6 +252,7 @@ class RuntimePipeline:
query.variables['_monitoring_has_error'] = True
# Record error to monitoring system
try:
await self._assert_execution_active(query)
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
message_id = query.variables.get('_monitoring_message_id', '')
@@ -194,6 +261,7 @@ class RuntimePipeline:
# Update message status to error
if message_id:
await self.ap.monitoring_service.update_message_status(
get_query_execution_context(query),
message_id=message_id,
status='error',
level='error',
@@ -201,6 +269,7 @@ class RuntimePipeline:
# Record error log
await self.ap.monitoring_service.record_error(
get_query_execution_context(query),
bot_id=query.bot_uuid or 'unknown',
bot_name=bot_name,
pipeline_id=self.pipeline_entity.uuid,
@@ -242,6 +311,7 @@ class RuntimePipeline:
i = stage_index
while i < len(self.stage_containers):
await self._assert_execution_active(query)
stage_container = self.stage_containers[i]
query.current_stage_name = stage_container.inst_name # 标记到 Query 对象里
@@ -250,6 +320,7 @@ class RuntimePipeline:
if isinstance(result, typing.Coroutine):
result = await result
await self._assert_execution_active(query)
if isinstance(result, pipeline_entities.StageProcessResult): # 直接返回结果
self.ap.logger.debug(
@@ -265,7 +336,14 @@ class RuntimePipeline:
elif isinstance(result, typing.AsyncGenerator): # 生成器
self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen')
async for sub_result in result:
iterator = result.__aiter__()
while True:
await self._assert_execution_active(query)
try:
sub_result = await anext(iterator)
except StopAsyncIteration:
break
await self._assert_execution_active(query)
self.ap.logger.debug(
f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}'
)
@@ -283,6 +361,7 @@ class RuntimePipeline:
async def process_query(self, query: pipeline_query.Query):
"""处理请求"""
await self._assert_execution_active(query)
# Get monitoring metadata
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
@@ -310,6 +389,7 @@ class RuntimePipeline:
query.variables['_monitoring_message_id'] = message_id
# Notify adapter so it can map platform-specific IDs to monitoring message ID
if hasattr(query.adapter, 'on_monitoring_message_created'):
await self._assert_execution_active(query)
await query.adapter.on_monitoring_message_created(query, message_id)
except Exception as e:
self.ap.logger.error(f'Failed to record query start: {e}')
@@ -334,7 +414,9 @@ class RuntimePipeline:
message_chain=query.message_chain,
)
await self._assert_execution_active(query)
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
await self._assert_execution_active(query)
if event_ctx.is_prevented_default():
self.ap.logger.debug(
@@ -349,6 +431,7 @@ class RuntimePipeline:
# Record query success only if no error occurred during processing
if not query.variables.get('_monitoring_has_error', False):
try:
await self._assert_execution_active(query)
await monitoring_helper.MonitoringHelper.record_query_success(
ap=self.ap,
message_id=message_id,
@@ -359,6 +442,7 @@ class RuntimePipeline:
# Record bot response message
try:
await self._assert_execution_active(query)
await monitoring_helper.MonitoringHelper.record_query_response(
ap=self.ap,
query=query,
@@ -371,6 +455,8 @@ class RuntimePipeline:
except Exception as e:
self.ap.logger.error(f'Failed to record query response: {e}')
except WorkspaceError as e:
self.ap.logger.info(f'Dropped query {query.query_id} because its Workspace execution binding is stale: {e}')
except Exception as e:
inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
@@ -380,6 +466,7 @@ class RuntimePipeline:
try:
from . import monitoring_helper
await self._assert_execution_active(query)
await monitoring_helper.MonitoringHelper.record_query_error(
ap=self.ap,
query=query,
@@ -395,7 +482,7 @@ class RuntimePipeline:
finally:
self.ap.logger.debug(f'Query {query.query_id} processed')
del self.ap.query_pool.cached_queries[query.query_id]
await self.ap.query_pool.remove_query(query)
class PipelineManager:
@@ -425,10 +512,38 @@ class PipelineManager:
# load pipelines
for pipeline in pipelines:
await self.load_pipeline(pipeline)
binding = await self.ap.workspace_service.get_execution_binding(pipeline.workspace_uuid)
await self.load_pipeline(
ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
pipeline_uuid=pipeline.uuid,
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
),
pipeline,
)
@staticmethod
def _normalize_execution_context(
context: ExecutionContext | RequestContext,
pipeline_uuid: str,
) -> ExecutionContext:
if isinstance(context, RequestContext):
return ExecutionContext.from_request(context, pipeline_uuid=pipeline_uuid)
if not isinstance(context, ExecutionContext):
raise WorkspaceRequiredError('Pipeline runtime operations require an ExecutionContext')
if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
raise WorkspaceRequiredError('Pipeline runtime operations require an instance and Workspace')
if context.placement_generation <= 0:
raise WorkspaceRequiredError('Pipeline runtime operations require a positive placement generation')
if context.pipeline_uuid not in (None, pipeline_uuid):
raise WorkspaceRequiredError('Pipeline UUID does not match its ExecutionContext')
return dataclasses.replace(context, pipeline_uuid=pipeline_uuid)
async def load_pipeline(
self,
context: ExecutionContext | RequestContext,
pipeline_entity: persistence_pipeline.LegacyPipeline
| sqlalchemy.Row[persistence_pipeline.LegacyPipeline]
| dict,
@@ -438,6 +553,14 @@ class PipelineManager:
elif isinstance(pipeline_entity, dict):
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
execution_context = self._normalize_execution_context(context, pipeline_entity.uuid)
if pipeline_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceRequiredError('Pipeline entity Workspace does not match its runtime context')
await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
coerce_pipeline_config(
pipeline_entity.config,
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
@@ -454,17 +577,36 @@ class PipelineManager:
for stage_container in stage_containers:
await stage_container.inst.initialize(pipeline_entity.config)
runtime_pipeline = RuntimePipeline(self.ap, pipeline_entity, stage_containers)
runtime_pipeline = RuntimePipeline(
self.ap,
pipeline_entity,
stage_containers,
execution_context,
)
self.pipelines.append(runtime_pipeline)
async def get_pipeline_by_uuid(self, uuid: str) -> RuntimePipeline | None:
async def get_pipeline_by_uuid(
self,
context: ExecutionContext | RequestContext,
uuid: str,
) -> RuntimePipeline | None:
execution_context = self._normalize_execution_context(context, uuid)
for pipeline in self.pipelines:
if pipeline.pipeline_entity.uuid == uuid:
if (
pipeline.workspace_uuid == execution_context.workspace_uuid
and pipeline.placement_generation == execution_context.placement_generation
and pipeline.pipeline_entity.uuid == uuid
):
return pipeline
return None
async def remove_pipeline(self, uuid: str):
async def remove_pipeline(
self,
context: ExecutionContext | RequestContext,
uuid: str,
) -> None:
execution_context = self._normalize_execution_context(context, uuid)
for pipeline in self.pipelines:
if pipeline.pipeline_entity.uuid == uuid:
if pipeline.workspace_uuid == execution_context.workspace_uuid and pipeline.pipeline_entity.uuid == uuid:
self.pipelines.remove(pipeline)
return
+249 -18
View File
@@ -1,57 +1,196 @@
from __future__ import annotations
import asyncio
import dataclasses
import inspect
import typing
import uuid
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from ..api.http.context import ExecutionContext
QueryCacheKey = tuple[str, str]
LegacyQueryKey = tuple[str, int]
QueryCounterKey = tuple[str, str, int]
SingletonContextResolver = typing.Callable[
[],
ExecutionContext | typing.Awaitable[ExecutionContext],
]
class ExecutionContextRequiredError(ValueError):
"""Raised when runtime work is created without a trusted Workspace scope."""
class ExecutionContextMismatchError(ValueError):
"""Raised when entity fields conflict with their trusted execution scope."""
class QueryNotFoundError(LookupError):
"""Raised when a query does not exist inside the requested Workspace."""
def _validate_execution_context(execution_context: ExecutionContext) -> None:
if not isinstance(execution_context, ExecutionContext):
raise ExecutionContextRequiredError('A trusted ExecutionContext is required')
if not isinstance(execution_context.instance_uuid, str) or not execution_context.instance_uuid.strip():
raise ExecutionContextRequiredError('ExecutionContext.instance_uuid is required')
if not isinstance(execution_context.workspace_uuid, str) or not execution_context.workspace_uuid.strip():
raise ExecutionContextRequiredError('ExecutionContext.workspace_uuid is required')
if (
isinstance(execution_context.placement_generation, bool)
or not isinstance(execution_context.placement_generation, int)
or execution_context.placement_generation <= 0
):
raise ExecutionContextRequiredError('ExecutionContext.placement_generation must be a positive integer')
for field_name in ('bot_uuid', 'pipeline_uuid', 'query_uuid'):
value = getattr(execution_context, field_name)
if value is not None and (not isinstance(value, str) or not value.strip()):
raise ExecutionContextRequiredError(f'ExecutionContext.{field_name} must be a non-empty string when set')
def bind_execution_context(
execution_context: ExecutionContext,
*,
bot_uuid: str | None = None,
pipeline_uuid: str | None = None,
query_uuid: str | None = None,
) -> ExecutionContext:
"""Bind runtime entity identifiers without allowing scope substitution."""
_validate_execution_context(execution_context)
requested_fields = {
'bot_uuid': bot_uuid,
'pipeline_uuid': pipeline_uuid,
'query_uuid': query_uuid,
}
updates: dict[str, str] = {}
for field_name, requested_value in requested_fields.items():
if requested_value is None:
continue
if not isinstance(requested_value, str) or not requested_value.strip():
raise ExecutionContextRequiredError(f'{field_name} must be a non-empty string')
current_value = getattr(execution_context, field_name)
if current_value is not None and current_value != requested_value:
raise ExecutionContextMismatchError(f'ExecutionContext.{field_name} does not match the runtime entity')
if current_value is None:
updates[field_name] = requested_value
if not updates:
return execution_context
return dataclasses.replace(execution_context, **updates)
def get_query_execution_context(query: pipeline_query.Query) -> ExecutionContext:
"""Return and validate the trusted context attached to a Query."""
attached_context = getattr(query, '_execution_context', None)
bot_uuid = getattr(query, 'bot_uuid', None)
pipeline_uuid = getattr(query, 'pipeline_uuid', None)
query_uuid = getattr(query, 'query_uuid', None)
if isinstance(attached_context, ExecutionContext):
return bind_execution_context(
attached_context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
)
raise ExecutionContextRequiredError('Query is missing its trusted ExecutionContext')
class QueryPool:
"""请求池,请求获得调度进入pipeline之前,保存在这里"""
query_id_counter: int = 0
"""Workspace-scoped queue of requests waiting for pipeline scheduling."""
query_id_counter: int
pool_lock: asyncio.Lock
queries: list[pipeline_query.Query]
cached_queries: dict[int, pipeline_query.Query]
"""Cached queries, used for plugin backward api call, will be removed after the query completely processed"""
cached_queries: dict[QueryCacheKey, pipeline_query.Query]
legacy_query_index: dict[LegacyQueryKey, str]
query_count_by_scope: dict[QueryCounterKey, int]
condition: asyncio.Condition
def __init__(self):
def __init__(
self,
singleton_context_resolver: SingletonContextResolver | None = None,
):
self.query_id_counter = 0
self.pool_lock = asyncio.Lock()
self.queries = []
self.cached_queries = {}
self.legacy_query_index = {}
self.query_count_by_scope = {}
self.condition = asyncio.Condition(self.pool_lock)
self._singleton_context_resolver = singleton_context_resolver
async def resolve_execution_context(
self,
execution_context: ExecutionContext | None,
*,
bot_uuid: str,
pipeline_uuid: str | None,
query_uuid: str | None = None,
) -> ExecutionContext:
"""Resolve an explicit scope or the opt-in OSS singleton scope."""
if execution_context is None:
if self._singleton_context_resolver is None:
raise ExecutionContextRequiredError('ExecutionContext is required; no singleton resolver is configured')
resolved_context = self._singleton_context_resolver()
if inspect.isawaitable(resolved_context):
resolved_context = await resolved_context
execution_context = resolved_context
return bind_execution_context(
execution_context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
)
async def add_query(
self,
bot_uuid: str,
launcher_type: provider_session.LauncherTypes,
launcher_id: typing.Union[int, str],
sender_id: typing.Union[int, str],
launcher_id: int | str,
sender_id: int | str,
message_event: platform_events.MessageEvent,
message_chain: platform_message.MessageChain,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
pipeline_uuid: str | None = None,
routed_by_rule: bool = False,
variables: typing.Optional[dict[str, typing.Any]] = None,
variables: dict[str, typing.Any] | None = None,
execution_context: ExecutionContext | None = None,
) -> pipeline_query.Query:
"""Create a query and cache it under an opaque, Workspace-scoped key."""
query_uuid = str(uuid.uuid4())
execution_context = await self.resolve_execution_context(
execution_context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
)
async with self.condition:
query_id = self.query_id_counter
initial_variables: dict[str, typing.Any] = {'_routed_by_rule': routed_by_rule}
if variables:
initial_variables.update(variables)
query = pipeline_query.Query(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
bot_uuid=bot_uuid,
query_id=query_id,
query_uuid=query_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_id,
@@ -63,12 +202,104 @@ class QueryPool:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
)
# langbot-plugin 0.4.13 ignores these forward-compatible fields.
# Attach them explicitly until the Workspace-aware SDK is released.
object.__setattr__(query, 'instance_uuid', execution_context.instance_uuid)
object.__setattr__(query, 'workspace_uuid', execution_context.workspace_uuid)
object.__setattr__(
query,
'placement_generation',
execution_context.placement_generation,
)
object.__setattr__(query, 'query_uuid', query_uuid)
object.__setattr__(query, '_execution_context', execution_context)
self.queries.append(query)
self.cached_queries[query_id] = query
self.cached_queries[(execution_context.workspace_uuid, query_uuid)] = query
self.legacy_query_index[(execution_context.workspace_uuid, query_id)] = query_uuid
self.query_id_counter += 1
counter_key = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
)
self.query_count_by_scope[counter_key] = self.query_count_by_scope.get(counter_key, 0) + 1
self.condition.notify_all()
return query
def get_query_count(self, execution_context: ExecutionContext) -> int:
"""Return the lifetime query count for one active placement scope."""
_validate_execution_context(execution_context)
return self.query_count_by_scope.get(
(
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
),
0,
)
async def get_query(
self,
workspace_uuid: str,
query_uuid: str,
) -> pipeline_query.Query | None:
"""Return a query only from the explicitly selected Workspace."""
async with self.pool_lock:
return self.cached_queries.get((workspace_uuid, query_uuid))
async def require_query(
self,
workspace_uuid: str,
query_uuid: str,
) -> pipeline_query.Query:
"""Return a scoped query or raise without checking other Workspaces."""
query = await self.get_query(workspace_uuid, query_uuid)
if query is None:
raise QueryNotFoundError(f'Query {query_uuid!r} was not found in Workspace {workspace_uuid!r}')
return query
async def get_query_by_legacy_id(
self,
workspace_uuid: str,
query_id: int,
) -> pipeline_query.Query | None:
"""Resolve a legacy integer ID within one explicit Workspace."""
async with self.pool_lock:
query_uuid = self.legacy_query_index.get((workspace_uuid, query_id))
if query_uuid is None:
return None
return self.cached_queries.get((workspace_uuid, query_uuid))
async def remove_query(self, query: pipeline_query.Query) -> bool:
"""Remove a query and both of its Workspace-scoped indexes."""
execution_context = get_query_execution_context(query)
query_uuid = execution_context.query_uuid
if query_uuid is None:
raise ExecutionContextRequiredError('Query.query_uuid is required for removal')
async with self.pool_lock:
cache_key = (execution_context.workspace_uuid, query_uuid)
cached_query = self.cached_queries.get(cache_key)
if cached_query is not query:
return False
del self.cached_queries[cache_key]
self.legacy_query_index.pop(
(execution_context.workspace_uuid, query.query_id),
None,
)
for index, queued_query in enumerate(self.queries):
if queued_query is query:
self.queries.pop(index)
break
return True
async def __aenter__(self):
await self.pool_lock.acquire()
return self
+21 -5
View File
@@ -8,6 +8,7 @@ import langbot_plugin.api.entities.events as events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.events as platform_events
from ...pipeline.pool import get_query_execution_context
@stage.stage_class('PreProcessor')
@@ -70,7 +71,10 @@ class PreProcessor(stage.PipelineStage):
if primary_uuid:
try:
llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
llm_model = await self.ap.model_mgr.get_model_by_uuid(
get_query_execution_context(query),
primary_uuid,
)
except ValueError:
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
@@ -79,7 +83,10 @@ class PreProcessor(stage.PipelineStage):
valid_fallbacks = []
for fb_uuid in fallback_uuids:
try:
await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
await self.ap.model_mgr.get_model_by_uuid(
get_query_execution_context(query),
fb_uuid,
)
valid_fallbacks.append(fb_uuid)
except ValueError:
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
@@ -131,6 +138,7 @@ class PreProcessor(stage.PipelineStage):
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
all_tools = await self.ap.tool_mgr.get_all_tools(
get_query_execution_context(query),
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
@@ -149,6 +157,7 @@ class PreProcessor(stage.PipelineStage):
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
all_tools = await self.ap.tool_mgr.get_all_tools(
get_query_execution_context(query),
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
@@ -279,7 +288,13 @@ class PreProcessor(stage.PipelineStage):
# relied on this injection; without it the LLM never discovers
# the skills are there and just calls native tools instead.
if selected_runner == 'local-agent' and self.ap.skill_mgr:
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
skill_execution_context = get_query_execution_context(query)
await self.ap.skill_mgr.ensure_loaded(skill_execution_context)
pipeline_data = await self.ap.pipeline_service.get_pipeline(
query.workspace_uuid,
query.pipeline_uuid,
include_secret=True,
)
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
@@ -291,6 +306,7 @@ class PreProcessor(stage.PipelineStage):
query.variables['_pipeline_bound_skills'] = bound_skills
skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition(
skill_execution_context,
bound_skills=bound_skills,
)
if skill_addition:
@@ -319,13 +335,13 @@ class PreProcessor(stage.PipelineStage):
f'Skill index injected into system prompt: '
f'pipeline={query.pipeline_uuid} '
f'bound_skills={bound_skills or "all"} '
f'loaded_skills={len(self.ap.skill_mgr.skills)}'
f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))}'
)
else:
self.ap.logger.debug(
f'No skills available for prompt injection: '
f'pipeline={query.pipeline_uuid} '
f'loaded_skills={len(self.ap.skill_mgr.skills)} '
f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))} '
f'bound_skills={bound_skills}'
)
@@ -19,6 +19,7 @@ from ....provider import runners
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ...pool import get_query_execution_context
importutil.import_modules_in_pkg(runners)
@@ -198,7 +199,10 @@ class ChatMessageHandler(handler.MessageHandler):
model_name = None
try:
if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None):
m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
m = await self.ap.model_mgr.get_model_by_uuid(
get_query_execution_context(query),
query.use_llm_model_uuid,
)
if m and getattr(m, 'model_entity', None):
model_name = getattr(m.model_entity, 'name', None)
except Exception:
+273 -32
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
import asyncio
import dataclasses
import json
import re
import traceback
import uuid
import sqlalchemy
from ..core import app, entities as core_entities, taskmgr
@@ -14,6 +16,8 @@ from ..entity.persistence import bot as persistence_bot
from ..entity.persistence import pipeline as persistence_pipeline
from ..entity.errors import platform as platform_errors
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
from ..api.http.authz import WorkspaceRequiredError
from .logger import EventLogger
@@ -40,20 +44,50 @@ class RuntimeBot:
logger: EventLogger
execution_context: ExecutionContext
workspace_uuid: str
placement_generation: int
def __init__(
self,
ap: app.Application,
bot_entity: persistence_bot.Bot,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
logger: EventLogger,
execution_context: ExecutionContext,
):
if not isinstance(execution_context, ExecutionContext):
raise WorkspaceRequiredError('RuntimeBot requires an ExecutionContext')
if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
raise WorkspaceRequiredError('RuntimeBot requires an instance and Workspace')
if execution_context.placement_generation <= 0:
raise WorkspaceRequiredError('RuntimeBot requires a positive placement generation')
entity_workspace_uuid = getattr(bot_entity, 'workspace_uuid', None)
if entity_workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceRequiredError('RuntimeBot entity Workspace does not match its ExecutionContext')
if execution_context.bot_uuid not in (None, bot_entity.uuid):
raise WorkspaceRequiredError('RuntimeBot bot UUID does not match its ExecutionContext')
self.ap = ap
self.bot_entity = bot_entity
self.execution_context = dataclasses.replace(execution_context, bot_uuid=bot_entity.uuid)
self.workspace_uuid = self.execution_context.workspace_uuid
self.placement_generation = self.execution_context.placement_generation
self.enable = bot_entity.enable
self.adapter = adapter
self.task_context = taskmgr.TaskContext()
self.logger = logger
async def assert_execution_active(self) -> None:
"""Fail closed when this long-lived adapter belongs to a stale placement."""
await self.ap.workspace_service.get_execution_binding(
self.workspace_uuid,
expected_generation=self.placement_generation,
)
@staticmethod
def _match_operator(actual: str, operator: str, expected: str) -> bool:
"""Evaluate a single operator condition."""
@@ -135,6 +169,28 @@ class RuntimeBot:
return self.bot_entity.use_pipeline_uuid, False
def resolve_event_pipeline_uuid(
self,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
launcher_type: str,
launcher_id: str,
message_text: str,
message_element_types: list[str] | None = None,
) -> tuple[str | None, bool]:
"""Resolve a pipeline, honoring a trusted per-task adapter override."""
get_override = getattr(adapter, 'get_pipeline_uuid_override', None)
if callable(get_override):
override = get_override()
if override:
return str(override), False
return self.resolve_pipeline_uuid(
launcher_type,
launcher_id,
message_text,
message_element_types,
)
async def _record_discarded_message(
self,
launcher_type: provider_session.LauncherTypes,
@@ -162,6 +218,7 @@ class RuntimeBot:
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
await self.ap.monitoring_service.record_message(
self.execution_context,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
pipeline_id=self.PIPELINE_DISCARD,
@@ -179,11 +236,13 @@ class RuntimeBot:
# Don't overwrite pipeline info — a session may have messages from
# multiple pipelines; discarding shouldn't change the displayed pipeline.
session_updated = await self.ap.monitoring_service.update_session_activity(
self.execution_context,
session_id,
)
if not session_updated:
# No session yet (first message for this launcher was discarded).
await self.ap.monitoring_service.record_session_start(
self.execution_context,
session_id=session_id,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
@@ -201,6 +260,7 @@ class RuntimeBot:
event: platform_events.FriendMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
await self.assert_execution_active()
image_components = [
component for component in event.message_chain if isinstance(component, platform_message.Image)
]
@@ -215,7 +275,10 @@ class RuntimeBot:
skip_pipeline = False
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
event, self.bot_entity.uuid, adapter.__class__.__name__
self.execution_context,
event,
self.bot_entity.uuid,
adapter.__class__.__name__,
)
# Only add to query pool if no webhook requested to skip pipeline
@@ -229,8 +292,12 @@ class RuntimeBot:
message_text = str(event.message_chain)
element_types = [comp.type for comp in event.message_chain]
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
'person', launcher_id, message_text, element_types
pipeline_uuid, routed_by_rule = self.resolve_event_pipeline_uuid(
adapter,
'person',
launcher_id,
message_text,
element_types,
)
if pipeline_uuid == self.PIPELINE_DISCARD:
@@ -254,6 +321,7 @@ class RuntimeBot:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
execution_context=self.execution_context,
)
else:
await self.logger.info('Pipeline skipped for person message due to webhook response')
@@ -262,6 +330,7 @@ class RuntimeBot:
event: platform_events.GroupMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
await self.assert_execution_active()
image_components = [
component for component in event.message_chain if isinstance(component, platform_message.Image)
]
@@ -276,7 +345,10 @@ class RuntimeBot:
skip_pipeline = False
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
event, self.bot_entity.uuid, adapter.__class__.__name__
self.execution_context,
event,
self.bot_entity.uuid,
adapter.__class__.__name__,
)
# Only add to query pool if no webhook requested to skip pipeline
@@ -290,8 +362,12 @@ class RuntimeBot:
message_text = str(event.message_chain)
element_types = [comp.type for comp in event.message_chain]
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
'group', launcher_id, message_text, element_types
pipeline_uuid, routed_by_rule = self.resolve_event_pipeline_uuid(
adapter,
'group',
launcher_id,
message_text,
element_types,
)
if pipeline_uuid == self.PIPELINE_DISCARD:
@@ -315,6 +391,7 @@ class RuntimeBot:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
execution_context=self.execution_context,
)
else:
await self.logger.info('Pipeline skipped for group message due to webhook response')
@@ -328,13 +405,15 @@ class RuntimeBot:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
try:
await self.assert_execution_active()
# Resolve pipeline name
pipeline_name = ''
if self.bot_entity.use_pipeline_uuid:
try:
pipeline_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid
persistence_pipeline.LegacyPipeline.workspace_uuid == self.workspace_uuid,
persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid,
)
)
pipeline_row = pipeline_result.first()
@@ -344,6 +423,7 @@ class RuntimeBot:
pass
await self.ap.monitoring_service.record_feedback(
self.execution_context,
feedback_id=event.feedback_id,
feedback_type=event.feedback_type,
feedback_content=event.feedback_content,
@@ -405,7 +485,7 @@ class PlatformManager:
bots: list[RuntimeBot]
websocket_proxy_bot: RuntimeBot
websocket_proxy_bots: dict[str, RuntimeBot]
adapter_components: list[engine.Component]
@@ -414,6 +494,7 @@ class PlatformManager:
def __init__(self, ap: app.Application = None):
self.ap = ap
self.bots = []
self.websocket_proxy_bots = {}
self.adapter_components = []
self.adapter_dict = {}
@@ -435,19 +516,104 @@ class PlatformManager:
if disabled_adapters:
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
# initialize websocket adapter
websocket_adapter_class = self.adapter_dict['websocket']
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
websocket_adapter_inst = websocket_adapter_class(
{},
websocket_logger,
ap=self.ap,
)
await self.load_bots_from_db()
self.websocket_proxy_bot = RuntimeBot(
# OSS may have no persisted bots. Its singleton Workspace still needs
# a debug WebSocket proxy. SaaS creates proxies lazily from an explicit
# request/runtime context instead of guessing among Workspaces.
if not self.websocket_proxy_bots:
try:
binding = await self.ap.workspace_service.get_execution_binding()
except Exception:
pass
else:
await self.get_websocket_proxy_bot(
ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
)
)
@property
def websocket_proxy_bot(self) -> RuntimeBot:
"""Compatibility accessor that is safe only for a singleton Workspace."""
if len(self.websocket_proxy_bots) != 1:
raise WorkspaceRequiredError('An explicit Workspace is required for the WebSocket proxy bot')
return next(iter(self.websocket_proxy_bots.values()))
@websocket_proxy_bot.setter
def websocket_proxy_bot(self, runtime_bot: RuntimeBot) -> None:
"""Keep isolated tests that inject one proxy bot working."""
workspace_uuid = getattr(runtime_bot, 'workspace_uuid', '__test_singleton__')
self.websocket_proxy_bots = {workspace_uuid: runtime_bot}
@staticmethod
def _normalize_execution_context(
context: ExecutionContext | RequestContext,
*,
bot_uuid: str | None = None,
pipeline_uuid: str | None = None,
) -> ExecutionContext:
if isinstance(context, RequestContext):
return ExecutionContext.from_request(
context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
)
if not isinstance(context, ExecutionContext):
raise WorkspaceRequiredError('Runtime operations require an ExecutionContext')
if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
raise WorkspaceRequiredError('Runtime operations require an instance and Workspace')
if context.placement_generation <= 0:
raise WorkspaceRequiredError('Runtime operations require a positive placement generation')
updates = {}
if bot_uuid is not None:
if context.bot_uuid not in (None, bot_uuid):
raise WorkspaceRequiredError('Runtime bot UUID does not match its ExecutionContext')
updates['bot_uuid'] = bot_uuid
if pipeline_uuid is not None:
if context.pipeline_uuid not in (None, pipeline_uuid):
raise WorkspaceRequiredError('Runtime pipeline UUID does not match its ExecutionContext')
updates['pipeline_uuid'] = pipeline_uuid
return dataclasses.replace(context, **updates) if updates else context
async def get_websocket_proxy_bot(
self,
context: ExecutionContext | RequestContext,
) -> RuntimeBot:
execution_context = self._normalize_execution_context(context)
existing = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
if existing is not None:
if existing.placement_generation != execution_context.placement_generation:
raise WorkspaceRequiredError('WebSocket proxy placement generation is stale')
return existing
binding = await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
websocket_adapter_class = self.adapter_dict['websocket']
websocket_logger = EventLogger(
name='websocket-adapter',
ap=self.ap,
execution_context=execution_context,
owner='websocket-proxy-bot',
)
websocket_adapter_inst = websocket_adapter_class({}, websocket_logger, ap=self.ap)
proxy_context = dataclasses.replace(
execution_context,
instance_uuid=binding.instance_uuid,
bot_uuid='websocket-proxy-bot',
)
runtime_bot = RuntimeBot(
ap=self.ap,
bot_entity=persistence_bot.Bot(
uuid='websocket-proxy-bot',
workspace_uuid=binding.workspace_uuid,
name='WebSocket',
description='',
adapter='websocket',
@@ -456,13 +622,20 @@ class PlatformManager:
),
adapter=websocket_adapter_inst,
logger=websocket_logger,
execution_context=proxy_context,
)
await self.websocket_proxy_bot.initialize()
await runtime_bot.initialize()
self.websocket_proxy_bots[binding.workspace_uuid] = runtime_bot
return runtime_bot
await self.load_bots_from_db()
def get_running_adapters(self) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
return [bot.adapter for bot in self.bots if bot.enable]
def get_running_adapters(
self,
context: ExecutionContext | RequestContext,
) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
execution_context = self._normalize_execution_context(context)
return [
bot.adapter for bot in self.bots if bot.enable and bot.workspace_uuid == execution_context.workspace_uuid
]
async def load_bots_from_db(self):
self.ap.logger.info('Loading bots from db...')
@@ -476,7 +649,15 @@ class PlatformManager:
for bot in bots:
# load all bots here, enable or disable will be handled in runtime
try:
await self.load_bot(bot)
binding = await self.ap.workspace_service.get_execution_binding(bot.workspace_uuid)
execution_context = ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
bot_uuid=bot.uuid,
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
)
await self.load_bot(execution_context, bot)
except platform_errors.AdapterNotFoundError as e:
self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}')
except Exception as e:
@@ -484,6 +665,7 @@ class PlatformManager:
async def load_bot(
self,
context: ExecutionContext | RequestContext,
bot_entity: persistence_bot.Bot | sqlalchemy.Row[persistence_bot.Bot] | dict,
) -> RuntimeBot:
"""加载机器人"""
@@ -492,7 +674,20 @@ class PlatformManager:
elif isinstance(bot_entity, dict):
bot_entity = persistence_bot.Bot(**bot_entity)
logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap)
execution_context = self._normalize_execution_context(context, bot_uuid=bot_entity.uuid)
if bot_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceRequiredError('Bot entity Workspace does not match its runtime context')
await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
logger = EventLogger(
name=f'platform-adapter-{bot_entity.name}',
ap=self.ap,
execution_context=execution_context,
owner=bot_entity.uuid,
)
if bot_entity.adapter not in self.adapter_dict:
raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
@@ -508,7 +703,13 @@ class PlatformManager:
if hasattr(adapter_inst, 'set_bot_uuid'):
adapter_inst.set_bot_uuid(bot_entity.uuid)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
runtime_bot = RuntimeBot(
ap=self.ap,
bot_entity=bot_entity,
adapter=adapter_inst,
logger=logger,
execution_context=execution_context,
)
await runtime_bot.initialize()
@@ -516,17 +717,53 @@ class PlatformManager:
return runtime_bot
async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid:
return self.websocket_proxy_bot
async def get_bot_by_uuid(
self,
context: ExecutionContext | RequestContext,
bot_uuid: str,
) -> RuntimeBot | None:
execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
proxy_bot = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
if proxy_bot and proxy_bot.bot_entity.uuid == bot_uuid:
if proxy_bot.placement_generation != execution_context.placement_generation:
return None
return proxy_bot
for bot in self.bots:
if bot.bot_entity.uuid == bot_uuid:
if (
bot.workspace_uuid == execution_context.workspace_uuid
and bot.placement_generation == execution_context.placement_generation
and bot.bot_entity.uuid == bot_uuid
):
return bot
return None
async def remove_bot(self, bot_uuid: str):
async def resolve_public_bot(self, route_key: str) -> RuntimeBot | None:
"""Resolve an opaque public bot UUID without consulting request headers."""
try:
normalized = str(uuid.UUID(route_key))
except (ValueError, AttributeError, TypeError):
return None
for bot in self.bots:
if bot.bot_entity.uuid == normalized:
try:
await self.ap.workspace_service.get_execution_binding(
bot.workspace_uuid,
expected_generation=bot.placement_generation,
)
except Exception:
return None
return bot
return None
async def remove_bot(
self,
context: ExecutionContext | RequestContext,
bot_uuid: str,
) -> None:
execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
for bot in self.bots[:]:
if bot.bot_entity.uuid == bot_uuid:
if bot.workspace_uuid == execution_context.workspace_uuid and bot.bot_entity.uuid == bot_uuid:
if bot.enable:
await bot.shutdown()
self.bots.remove(bot)
@@ -551,13 +788,17 @@ class PlatformManager:
async def run(self):
# This method will only be called when the application launching
await self.websocket_proxy_bot.run()
for proxy_bot in self.websocket_proxy_bots.values():
await proxy_bot.run()
for bot in self.bots:
if bot.enable:
await bot.run()
async def shutdown(self):
for proxy_bot in self.websocket_proxy_bots.values():
if proxy_bot.enable:
await proxy_bot.shutdown()
for bot in self.bots:
if bot.enable:
await bot.shutdown()
+22 -3
View File
@@ -9,6 +9,7 @@ import traceback
import uuid
from ..core import app
from ..api.http.context import ExecutionContext
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_event_logger
@@ -65,13 +66,21 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
logs: list[EventLog]
execution_context: ExecutionContext
owner: str
def __init__(
self,
name: str,
ap: app.Application,
execution_context: ExecutionContext,
owner: str,
):
self.name = name
self.ap = ap
self.execution_context = execution_context
self.owner = owner
self.logs = []
self.seq_id_inc = 0
@@ -121,7 +130,11 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
if len(self.logs) > MAX_LOG_COUNT:
for i in range(DELETE_COUNT_PER_TIME):
for image_key in self.logs[i].images: # type: ignore
await self.ap.storage_mgr.storage_provider.delete(image_key)
await self.ap.storage_mgr.delete_scoped_object_key(
self.execution_context,
image_key,
expected_owner_type='bot_log',
)
self.logs = self.logs[DELETE_COUNT_PER_TIME:]
async def _add_log(
@@ -149,8 +162,14 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
extension = mimetypes.guess_extension(mime_type)
if extension is None:
extension = '.jpg'
image_key = f'bot_log_images/{message_session_id}-{uuid.uuid4()}{extension}'
await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes)
logical_key = f'{message_session_id}-{uuid.uuid4()}{extension}'
image_key = await self.ap.storage_mgr.save_scoped(
self.execution_context,
owner_type='bot_log',
owner=self.owner,
key=logical_key,
value=img_bytes,
)
image_keys.append(image_key)
self.logs.append(
+28 -6
View File
@@ -311,18 +311,40 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def _reset_session(self, launcher_type: str, launcher_id: str) -> bool:
"""Drop the matching session so the next message starts a fresh conversation."""
execution_context = getattr(self.logger, 'execution_context', None)
if (
execution_context is None
or not execution_context.instance_uuid
or not execution_context.workspace_uuid
or execution_context.placement_generation <= 0
or not self.bot_uuid
):
raise RuntimeError('http_bot reset requires a trusted execution scope')
expected_prefix = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
self.bot_uuid,
launcher_type,
)
sess_mgr = self.ap.sess_mgr
before = len(sess_mgr.session_list)
sess_mgr.session_list = [
s
for s in sess_mgr.session_list
if not (
str(s.launcher_type.value if hasattr(s.launcher_type, 'value') else s.launcher_type) == launcher_type
and str(s.launcher_id) == launcher_id
)
s for s in sess_mgr.session_list if not self._matches_session_scope(s, expected_prefix, launcher_id)
]
return len(sess_mgr.session_list) < before
@staticmethod
def _matches_session_scope(session, expected_prefix: tuple[str, str, int, str, str], launcher_id: str) -> bool:
session_key = getattr(session, '_langbot_session_key', None)
return (
isinstance(session_key, tuple)
and len(session_key) == 6
and session_key[:5] == expected_prefix
and str(session_key[5]) == launcher_id
)
# -- outbound -------------------------------------------------------------
@staticmethod
@@ -33,6 +33,8 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.api.http.context import ExecutionContext
class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Converts between LangBot MessageChain and OpenClaw WeChat message items."""
@@ -278,8 +280,22 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda
return
try:
ap = self.logger.ap
execution_context = getattr(self.logger, 'execution_context', None)
if not isinstance(execution_context, ExecutionContext):
raise RuntimeError('Weixin Bot config persistence requires an ExecutionContext')
if execution_context.bot_uuid != self._bot_uuid:
raise RuntimeError('Weixin Bot UUID does not match its ExecutionContext')
binding = await ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
if binding.instance_uuid != execution_context.instance_uuid:
raise RuntimeError('Weixin Bot Workspace belongs to another LangBot instance')
await ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bot.Bot)
.where(persistence_bot.Bot.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_bot.Bot.uuid == self._bot_uuid)
.values(adapter_config=self.config)
)
@@ -1,6 +1,7 @@
"""WebSocket适配器 - 支持双向通信的IM系统"""
import asyncio
import contextvars
import logging
import typing
from datetime import datetime
@@ -13,9 +14,13 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from ...core import app
from .websocket_manager import WebSocketConnection, is_valid_session_id, ws_connection_manager
from .websocket_manager import WebSocketConnection, WebSocketScope, is_valid_session_id, ws_connection_manager
logger = logging.getLogger(__name__)
_current_pipeline_uuid: contextvars.ContextVar[str | None] = contextvars.ContextVar(
'websocket_pipeline_uuid',
default=None,
)
class WebSocketMessage(pydantic.BaseModel):
@@ -113,9 +118,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
return None
return pipeline_uuid, session_id
@classmethod
async def _get_connection_from_target(cls, target_id: str):
def _scope(self) -> WebSocketScope:
"""Return this adapter's immutable runtime placement."""
return WebSocketScope.from_context(self.logger.execution_context)
def get_pipeline_uuid_override(self) -> str | None:
"""Return the connection pipeline propagated into the listener task."""
return _current_pipeline_uuid.get()
async def _get_connection_from_target(self, target_id: str):
"""Resolve a person or group WebSocket launcher to its connection."""
scope = self._scope()
target_value = str(target_id)
for prefix in ('websocket_', 'websocketgroup_'):
if target_value.startswith(prefix):
@@ -123,14 +138,18 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
break
else:
return None
connection = await ws_connection_manager.get_connection(target)
connection = await ws_connection_manager.get_connection(target, scope=scope)
if connection is not None:
return connection
embed_target = cls._parse_embed_target(target_id)
embed_target = self._parse_embed_target(target_id)
if embed_target is not None:
pipeline_uuid, session_id = embed_target
return await ws_connection_manager.get_connection_by_session_id(session_id, pipeline_uuid)
return await ws_connection_manager.get_connection_by_session_id(target)
return await ws_connection_manager.get_connection_by_session_id(
session_id,
scope=scope,
pipeline_uuid=pipeline_uuid,
)
return await ws_connection_manager.get_connection_by_session_id(target, scope=scope)
async def _get_message_context(self, message_source) -> tuple[str, str | None]:
"""Resolve the originating pipeline and browser session for a reply."""
@@ -142,7 +161,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
embed_target = self._parse_embed_target(sender_id)
if embed_target is not None:
return embed_target
return typing.cast(str, self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid), None
raise ValueError('WebSocket reply target is not bound to this adapter scope')
async def send_message(
self,
@@ -160,16 +179,17 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if connection is not None:
pipeline_uuid = connection.pipeline_uuid
session_id = connection.session_id
scope = connection.scope
else:
embed_target = self._parse_embed_target(target_id)
if embed_target is not None:
pipeline_uuid, session_id = embed_target
else:
pipeline_uuid = typing.cast(
str,
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid,
)
pipeline_uuid = str(target_id).strip()
if not pipeline_uuid:
raise ValueError('WebSocket target pipeline is required')
session_id = None
scope = self._scope()
session_type = 'group' if target_type == 'group' else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
@@ -195,6 +215,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': message_data.model_dump(),
},
scope=scope,
session_type=session_type,
session_id=session_id,
)
@@ -216,6 +237,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
)
pipeline_uuid, session_id = await self._get_message_context(message_source)
scope = self._scope()
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
@@ -239,6 +261,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': message_data.model_dump(),
},
scope=scope,
session_type=session_type,
session_id=session_id,
)
@@ -262,6 +285,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
)
pipeline_uuid, session_id = await self._get_message_context(message_source)
scope = self._scope()
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
message_list = session.get_message_list(conversation_key)
@@ -316,6 +340,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': message_data.model_dump(),
},
scope=scope,
session_type=session_type,
session_id=session_id,
)
@@ -360,7 +385,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
message = await asyncio.wait_for(self.outbound_message_queue.get(), timeout=0.1)
# 广播到所有相关连接
target_id = message.get('target_id', '')
await ws_connection_manager.broadcast_to_pipeline(target_id, message)
await ws_connection_manager.broadcast_to_pipeline(
target_id,
message,
scope=self._scope(),
)
except asyncio.TimeoutError:
pass
@@ -372,7 +401,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
"""停止适配器"""
pass
async def _process_image_components(self, message_chain_obj: list):
async def _process_image_components(
self,
connection: WebSocketConnection,
message_chain_obj: list,
):
"""
处理消息链中的图片语音和文件组件 path 转换为 base64
@@ -387,14 +420,28 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
import base64
import mimetypes
storage_mgr = self.ap.storage_mgr
attachments = [
component
for component in message_chain_obj
if component.get('path') and component.get('type') in ('Image', 'Voice', 'File')
]
if not attachments:
return
for component in message_chain_obj:
storage_mgr = self.ap.storage_mgr
execution_context = connection.execution_context
expected_prefix = storage_mgr.scoped_prefix(execution_context, owner_type='upload_image')
for component in attachments:
comp_type = component.get('type', '')
comp_path = component.get('path', '')
if not comp_path or comp_type not in ('Image', 'Voice', 'File'):
continue
if not comp_path.startswith(expected_prefix) or not storage_mgr.is_scoped_object_key(
comp_path,
expected_owner_type='upload_image',
):
await self.logger.warning(f'Rejected {comp_type} attachment outside the WebSocket connection scope')
raise ValueError('Attachment key does not belong to this WebSocket connection')
try:
file_content = await storage_mgr.storage_provider.load(comp_path)
@@ -416,10 +463,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
component['base64'] = f'data:{mime_type};base64,{base64_str}'
await storage_mgr.storage_provider.delete(comp_path)
await storage_mgr.delete_scoped_object_key(
execution_context,
comp_path,
expected_owner_type='upload_image',
)
component['path'] = ''
except Exception as e:
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
raise
async def handle_websocket_message(
self,
@@ -451,7 +503,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
message_chain_obj = message_data.get('message', [])
await self._process_image_components(message_chain_obj)
await self._process_image_components(connection, message_chain_obj)
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
@@ -476,6 +528,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': user_message.model_dump(),
},
scope=connection.scope,
session_type=session_type,
session_id=connection.session_id,
)
@@ -506,11 +559,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
# 设置流水线UUID (proxy bot always needs it for reply_message routing)
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
if owner_bot is not None:
owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
# 异步触发事件处理
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
listeners = (
@@ -525,7 +573,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
owner_bot.adapter.set_ws_adapter(self)
callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self
if event.__class__ in listeners:
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
token = _current_pipeline_uuid.set(pipeline_uuid)
try:
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
finally:
_current_pipeline_uuid.reset(token)
def get_websocket_messages(
self,
@@ -558,11 +610,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if session_type == 'group'
else f'websocket_{pipeline_uuid}:{session_id}'
)
scope = self._scope()
self.ap.sess_mgr.session_list = [
candidate_session
for candidate_session in self.ap.sess_mgr.session_list
if not (
str(
getattr(candidate_session, 'instance_uuid', None) == scope.instance_uuid
and getattr(candidate_session, 'workspace_uuid', None) == scope.workspace_uuid
and getattr(candidate_session, 'placement_generation', None) == scope.placement_generation
and str(
candidate_session.launcher_type.value
if hasattr(candidate_session.launcher_type, 'value')
else candidate_session.launcher_type
@@ -1,6 +1,7 @@
"""WebSocket连接管理器 - 管理多个并发WebSocket连接"""
import asyncio
import dataclasses
import logging
import typing
import uuid
@@ -8,10 +9,35 @@ from datetime import datetime
import pydantic
from ...api.http.context import ExecutionContext
logger = logging.getLogger(__name__)
_SESSION_FILTER_UNSET = object()
@dataclasses.dataclass(frozen=True, slots=True)
class WebSocketScope:
"""Trusted runtime placement carried by every WebSocket connection."""
instance_uuid: str
workspace_uuid: str
placement_generation: int
def __post_init__(self) -> None:
if not self.instance_uuid.strip() or not self.workspace_uuid.strip():
raise ValueError('WebSocket scope requires an instance and Workspace')
if self.placement_generation <= 0:
raise ValueError('WebSocket scope requires a positive placement generation')
@classmethod
def from_context(cls, context: typing.Any) -> 'WebSocketScope':
return cls(
instance_uuid=str(getattr(context, 'instance_uuid', '')),
workspace_uuid=str(getattr(context, 'workspace_uuid', '')),
placement_generation=int(getattr(context, 'placement_generation', 0)),
)
def is_valid_session_id(value: str) -> bool:
"""Accept only canonical random UUIDs for client conversation identifiers."""
try:
@@ -29,6 +55,15 @@ class WebSocketConnection(pydantic.BaseModel):
connection_id: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4()))
"""连接唯一ID"""
instance_uuid: str
"""Owning LangBot instance."""
workspace_uuid: str
"""Owning Workspace."""
placement_generation: int
"""Workspace placement generation captured at connect time."""
pipeline_uuid: str
"""关联的流水线UUID"""
@@ -56,6 +91,25 @@ class WebSocketConnection(pydantic.BaseModel):
metadata: dict = pydantic.Field(default_factory=dict)
"""连接元数据(可存储额外信息)"""
@property
def scope(self) -> WebSocketScope:
return WebSocketScope(
instance_uuid=self.instance_uuid,
workspace_uuid=self.workspace_uuid,
placement_generation=self.placement_generation,
)
@property
def execution_context(self) -> ExecutionContext:
"""Return the storage/runtime context captured for this connection."""
return ExecutionContext(
instance_uuid=self.instance_uuid,
workspace_uuid=self.workspace_uuid,
placement_generation=self.placement_generation,
pipeline_uuid=self.pipeline_uuid,
)
class WebSocketConnectionManager:
"""WebSocket连接管理器 - 支持多连接并发"""
@@ -64,11 +118,11 @@ class WebSocketConnectionManager:
self.connections: dict[str, WebSocketConnection] = {}
"""所有活跃连接 {connection_id: connection}"""
self.pipeline_connections: dict[str, set[str]] = {}
"""流水线到连接的映射 {pipeline_uuid: {connection_id, ...}}"""
self.pipeline_connections: dict[tuple[str, str, int, str], set[str]] = {}
"""Scoped pipeline to connection mapping."""
self.session_connections: dict[str, set[str]] = {}
"""会话类型到连接的映射 {session_type: {connection_id, ...}}"""
self.session_connections: dict[tuple[str, str, int, str], set[str]] = {}
"""Scoped session-type to connection mapping."""
self._lock = asyncio.Lock()
"""线程锁,保护并发访问"""
@@ -76,6 +130,7 @@ class WebSocketConnectionManager:
async def add_connection(
self,
websocket: typing.Any,
scope: WebSocketScope,
pipeline_uuid: str,
session_type: str,
metadata: dict | None = None,
@@ -84,6 +139,9 @@ class WebSocketConnectionManager:
"""Register a WebSocket connection and its optional embed session."""
async with self._lock:
connection = WebSocketConnection(
instance_uuid=scope.instance_uuid,
workspace_uuid=scope.workspace_uuid,
placement_generation=scope.placement_generation,
pipeline_uuid=pipeline_uuid,
session_type=session_type,
session_id=session_id,
@@ -94,18 +152,21 @@ class WebSocketConnectionManager:
self.connections[connection.connection_id] = connection
# 更新流水线映射
if pipeline_uuid not in self.pipeline_connections:
self.pipeline_connections[pipeline_uuid] = set()
self.pipeline_connections[pipeline_uuid].add(connection.connection_id)
pipeline_key = self._pipeline_key(scope, pipeline_uuid)
if pipeline_key not in self.pipeline_connections:
self.pipeline_connections[pipeline_key] = set()
self.pipeline_connections[pipeline_key].add(connection.connection_id)
# 更新会话类型映射
if session_type not in self.session_connections:
self.session_connections[session_type] = set()
self.session_connections[session_type].add(connection.connection_id)
session_key = self._session_key(scope, session_type)
if session_key not in self.session_connections:
self.session_connections[session_key] = set()
self.session_connections[session_key].add(connection.connection_id)
logger.debug(
f'WebSocket connection established: {connection.connection_id} '
f'(pipeline={pipeline_uuid}, session_type={session_type})'
f'(workspace={scope.workspace_uuid}, generation={scope.placement_generation}, '
f'pipeline={pipeline_uuid}, session_type={session_type})'
)
return connection
@@ -120,28 +181,59 @@ class WebSocketConnectionManager:
connection.is_active = False
# 从流水线映射中移除
if connection.pipeline_uuid in self.pipeline_connections:
self.pipeline_connections[connection.pipeline_uuid].discard(connection_id)
if not self.pipeline_connections[connection.pipeline_uuid]:
del self.pipeline_connections[connection.pipeline_uuid]
pipeline_key = self._pipeline_key(connection.scope, connection.pipeline_uuid)
if pipeline_key in self.pipeline_connections:
self.pipeline_connections[pipeline_key].discard(connection_id)
if not self.pipeline_connections[pipeline_key]:
del self.pipeline_connections[pipeline_key]
# 从会话类型映射中移除
if connection.session_type in self.session_connections:
self.session_connections[connection.session_type].discard(connection_id)
if not self.session_connections[connection.session_type]:
del self.session_connections[connection.session_type]
session_key = self._session_key(connection.scope, connection.session_type)
if session_key in self.session_connections:
self.session_connections[session_key].discard(connection_id)
if not self.session_connections[session_key]:
del self.session_connections[session_key]
del self.connections[connection_id]
logger.debug(f'WebSocket connection disconnected: {connection_id}')
async def get_connection(self, connection_id: str) -> WebSocketConnection | None:
"""Get a connection by its transport identifier."""
return self.connections.get(connection_id)
@staticmethod
def _pipeline_key(scope: WebSocketScope, pipeline_uuid: str) -> tuple[str, str, int, str]:
return (
scope.instance_uuid,
scope.workspace_uuid,
scope.placement_generation,
pipeline_uuid,
)
@staticmethod
def _session_key(scope: WebSocketScope, session_type: str) -> tuple[str, str, int, str]:
return (
scope.instance_uuid,
scope.workspace_uuid,
scope.placement_generation,
session_type,
)
async def get_connection(
self,
connection_id: str,
*,
scope: WebSocketScope,
) -> WebSocketConnection | None:
"""Get a connection only when it belongs to the expected placement."""
connection = self.connections.get(connection_id)
if connection is None or connection.scope != scope:
return None
return connection
async def get_connection_by_session_id(
self,
session_id: str,
*,
scope: WebSocketScope,
pipeline_uuid: str | None = None,
) -> WebSocketConnection | None:
"""Get an active embed connection by its stable browser session identifier."""
@@ -149,25 +241,38 @@ class WebSocketConnectionManager:
if (
connection.session_id == session_id
and connection.is_active
and connection.scope == scope
and (pipeline_uuid is None or connection.pipeline_uuid == pipeline_uuid)
):
return connection
return None
async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
async def get_connections_by_pipeline(
self,
pipeline_uuid: str,
*,
scope: WebSocketScope,
) -> list[WebSocketConnection]:
"""获取指定流水线的所有连接"""
connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
connection_ids = self.pipeline_connections.get(self._pipeline_key(scope, pipeline_uuid), set())
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
async def get_connections_by_session_type(self, session_type: str) -> list[WebSocketConnection]:
async def get_connections_by_session_type(
self,
session_type: str,
*,
scope: WebSocketScope,
) -> list[WebSocketConnection]:
"""获取指定会话类型的所有连接"""
connection_ids = self.session_connections.get(session_type, set())
connection_ids = self.session_connections.get(self._session_key(scope, session_type), set())
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
async def broadcast_to_pipeline(
self,
pipeline_uuid: str,
message: dict,
*,
scope: WebSocketScope,
session_type: str | None = None,
session_id: typing.Any = _SESSION_FILTER_UNSET,
):
@@ -180,7 +285,7 @@ class WebSocketConnectionManager:
session_id: Embed conversation filter. Omit it to broadcast across
conversations; pass ``None`` to target non-embed connections.
"""
connections = await self.get_connections_by_pipeline(pipeline_uuid)
connections = await self.get_connections_by_pipeline(pipeline_uuid, scope=scope)
if session_type is not None:
connections = [conn for conn in connections if conn.session_type == session_type]
@@ -196,7 +301,7 @@ class WebSocketConnectionManager:
async def send_to_connection(self, connection_id: str, message: dict):
"""向指定连接发送消息"""
connection = await self.get_connection(connection_id)
connection = self.connections.get(connection_id)
if not connection or not connection.is_active:
logger.warning(f'Attempt to send message to invalid connection: {connection_id}')
return
@@ -210,17 +315,24 @@ class WebSocketConnectionManager:
async def update_activity(self, connection_id: str):
"""更新连接活跃时间"""
connection = await self.get_connection(connection_id)
connection = self.connections.get(connection_id)
if connection:
connection.last_active = datetime.now()
def get_stats(self) -> dict:
"""获取连接统计信息"""
def get_stats(self, *, scope: WebSocketScope) -> dict:
"""Return connection statistics for one trusted placement."""
scoped_connections = [connection for connection in self.connections.values() if connection.scope == scope]
pipelines: dict[str, int] = {}
session_types: dict[str, int] = {}
for connection in scoped_connections:
pipelines[connection.pipeline_uuid] = pipelines.get(connection.pipeline_uuid, 0) + 1
session_types[connection.session_type] = session_types.get(connection.session_type, 0) + 1
return {
'total_connections': len(self.connections),
'pipelines': len(self.pipeline_connections),
'connections_by_pipeline': {k: len(v) for k, v in self.pipeline_connections.items()},
'connections_by_session_type': {k: len(v) for k, v in self.session_connections.items()},
'total_connections': len(scoped_connections),
'pipelines': len(pipelines),
'connections_by_pipeline': pipelines,
'connections_by_session_type': session_types,
}
+17 -4
View File
@@ -5,6 +5,7 @@ import logging
import aiohttp
from langbot.pkg.utils import httpclient
from langbot.pkg.api.http.context import ExecutionContext
import uuid
from typing import TYPE_CHECKING
@@ -24,14 +25,20 @@ class WebhookPusher:
self.ap = ap
self.logger = self.ap.logger
async def push_person_message(self, event: platform_events.FriendMessage, bot_uuid: str, adapter_name: str) -> bool:
async def push_person_message(
self,
execution_context: ExecutionContext,
event: platform_events.FriendMessage,
bot_uuid: str,
adapter_name: str,
) -> bool:
"""Push person message event to webhooks
Returns:
bool: True if any webhook responded with skip_pipeline=true, False otherwise
"""
try:
webhooks = await self.ap.webhook_service.get_enabled_webhooks()
webhooks = await self.ap.webhook_service.get_enabled_webhooks(execution_context)
if not webhooks:
return False
@@ -67,14 +74,20 @@ class WebhookPusher:
self.logger.error(f'Failed to push person message to webhooks: {e}')
return False
async def push_group_message(self, event: platform_events.GroupMessage, bot_uuid: str, adapter_name: str) -> bool:
async def push_group_message(
self,
execution_context: ExecutionContext,
event: platform_events.GroupMessage,
bot_uuid: str,
adapter_name: str,
) -> bool:
"""Push group message event to webhooks
Returns:
bool: True if any webhook responded with skip_pipeline=true, False otherwise
"""
try:
webhooks = await self.ap.webhook_service.get_enabled_webhooks()
webhooks = await self.ap.webhook_service.get_enabled_webhooks(execution_context)
if not webhooks:
return False
+236 -166
View File
@@ -9,6 +9,7 @@ import zipfile
from typing import Any
import typing
import os
import secrets
import sys
import httpx
import sqlalchemy
@@ -33,8 +34,17 @@ from langbot_plugin.api.entities.builtin.command import (
errors as command_errors,
)
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
from langbot_plugin.runtime.security import (
PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER,
validate_runtime_secret,
)
from langbot_plugin.entities.io.context import ActionContext
from ..core import taskmgr
from ..entity.persistence import plugin as persistence_plugin
from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from ..workspace.errors import WorkspaceNotFoundError
_CONNECT_TIMEOUT_SEC = 30.0
@@ -73,28 +83,68 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
runtime_disconnect_callback: typing.Callable[
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
],
action_context: ActionContext | None = None,
):
super().__init__(ap)
self.runtime_disconnect_callback = runtime_disconnect_callback
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
self._transport_task: asyncio.Task | None = None
self._reconnect_task: asyncio.Task | None = None
self._generation = 0
self._connected = asyncio.Event()
self._configured_action_context = (
ActionContext.model_validate(action_context).without_installation() if action_context is not None else None
)
self._control_token = str(os.environ.get(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV) or '').strip()
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
return runtime_handler
def _requires_explicit_workspace_binding(self) -> bool:
"""Return whether this process may host more than the OSS singleton."""
def _runtime_available(self) -> bool:
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
return False
# Unit-level and explicitly injected handlers don't own a transport.
# A managed transport must also have completed its handshake.
return self._transport_task is None or self._connected.is_set()
workspace_service = getattr(self.ap, 'workspace_service', None)
policy = getattr(workspace_service, 'policy', None)
return getattr(policy, 'multi_workspace_enabled', False) is True
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
try:
self._control_token = validate_runtime_secret(
self._control_token,
name=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
)
except ValueError as exc:
raise PluginRuntimeNotConnectedError(
f'{PLUGIN_RUNTIME_CONTROL_TOKEN_ENV} must be configured with a strong shared secret '
'for an external Plugin Runtime'
) from exc
return {PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER: self._control_token}
async def _resolve_action_context(self) -> ActionContext:
"""Resolve the trusted connector binding; never use plugin input."""
workspace_service = getattr(self.ap, 'workspace_service', None)
if workspace_service is None:
raise RuntimeError('Plugin Runtime Workspace binding is unavailable')
if self._configured_action_context is not None:
configured = self._configured_action_context
binding = await workspace_service.get_execution_binding(
configured.workspace_uuid,
expected_generation=configured.placement_generation,
)
if binding.instance_uuid != configured.instance_uuid:
raise RuntimeError('Plugin Runtime Workspace binding belongs to another instance')
return ActionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
)
if self._requires_explicit_workspace_binding():
raise RuntimeError('Cloud plugin Runtime connectors require an explicit projected Workspace binding')
binding = await workspace_service.get_local_execution_binding()
return ActionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
)
async def heartbeat_loop(self):
failures = 0
@@ -118,6 +168,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self.is_enable_plugin:
self.ap.logger.info('Plugin system is disabled.')
return
if self._configured_action_context is None and self._requires_explicit_workspace_binding():
raise RuntimeError('Cloud plugin Runtime connectors require an explicit projected Workspace binding')
async with self._lifecycle_lock:
if self._closing:
@@ -158,31 +210,29 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
await notify_disconnect()
return False
runtime_handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
self.handler = runtime_handler
self.handler_task = asyncio.create_task(runtime_handler.run())
try:
await runtime_handler.ping()
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
if space_url:
await runtime_handler.set_runtime_config(cloud_service_url=space_url)
if generation == self._generation and not self._closing:
connection_ready = True
self._connected.set()
self.ap.logger.info('Connected to plugin runtime.')
await self.handler_task
except asyncio.CancelledError:
raise
except Exception as exc:
if not self._connected.is_set():
connect_errors.append(exc)
self._connected.set()
finally:
if generation == self._generation and not self._closing:
self._connected.clear()
if getattr(self, 'handler', None) is runtime_handler:
del self.handler
await notify_disconnect()
action_context = await self._resolve_action_context()
self.handler = handler.RuntimeConnectionHandler(
connection,
disconnect_callback,
self.ap,
action_context,
)
self.handler_task = asyncio.create_task(self.handler.run())
_ = await self.handler.ping()
# Push the configured marketplace (Space) URL to the runtime so it
# downloads plugins from the same Space LangBot is bound to, rather
# than relying on the runtime's own env/default.
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
try:
await self.handler.set_runtime_config(cloud_service_url=space_url or None)
if space_url:
self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
except Exception as e:
self.ap.logger.warning(f'Failed to bind plugin runtime config: {e}')
raise
self.ap.logger.info('Connected to plugin runtime.')
await self.handler_task
task_coro: typing.Coroutine
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
@@ -191,86 +241,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
'ws://langbot_plugin_runtime:5400/control/ws',
)
async def connection_failed(ctrl, exc=None):
error = exc or RuntimeError('WebSocket connection failed')
connect_errors.append(error)
self._connected.set()
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=connection_failed,
)
task_coro = self.ctrl.run(new_connection_callback)
elif platform.get_platform() == 'win32':
await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt')
ws_url = 'ws://localhost:5400/control/ws'
async def connection_failed(ctrl, exc=None):
error = exc or RuntimeError('WebSocket connection failed')
connect_errors.append(error)
self._connected.set()
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=connection_failed,
)
task_coro = self.ctrl.run(new_connection_callback)
else:
self.ctrl = stdio_client_controller.StdioClientController(
command=sys.executable,
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
env=os.environ.copy(),
capture_stderr=False,
)
task_coro = self.ctrl.run(new_connection_callback)
self._transport_task = asyncio.create_task(task_coro)
try:
await asyncio.wait_for(self._connected.wait(), timeout=_CONNECT_TIMEOUT_SEC)
except asyncio.TimeoutError as exc:
await self._stop_transport()
raise PluginRuntimeNotConnectedError('Plugin runtime did not become ready within 30 seconds') from exc
if connect_errors:
await self._stop_transport()
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
if self.heartbeat_task is None or self.heartbeat_task.done():
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
def schedule_reconnect(self) -> None:
if self._closing or not self.is_enable_plugin:
return
if self._reconnect_task is not None and not self._reconnect_task.done():
return
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
async def _reconnect_loop(self) -> None:
delay = 1.0
try:
while not self._closing:
try:
await self.initialize()
return
except Exception as exc:
self.ap.logger.warning(f'Plugin runtime reconnection failed: {exc}; retrying in {delay:.0f}s')
await asyncio.sleep(delay)
delay = min(delay * 2, _RECONNECT_MAX_DELAY_SEC)
finally:
self._reconnect_task = None
async def _stop_transport(self) -> None:
self._connected.clear()
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is not None:
with contextlib.suppress(Exception):
await runtime_handler.close()
if getattr(self, 'handler', None) is runtime_handler:
del self.handler
tasks = [
task
for task in (
getattr(self, 'handler_task', None),
self._transport_task,
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime(): # use websocket
self.ap.logger.info('use websocket to connect to plugin runtime')
control_headers = self._control_headers(allow_generate=False)
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
'runtime_ws_url', 'ws://langbot_plugin_runtime:5400/control/ws'
)
if task is not None and task is not asyncio.current_task()
]
@@ -286,20 +261,73 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
with contextlib.suppress(Exception):
await close_ctrl()
async def aclose(self) -> None:
self._closing = True
self._generation += 1
reconnect_task = self._reconnect_task
self._reconnect_task = None
if reconnect_task is not None and reconnect_task is not asyncio.current_task():
reconnect_task.cancel()
await asyncio.gather(reconnect_task, return_exceptions=True)
if self.heartbeat_task is not None:
self.heartbeat_task.cancel()
await asyncio.gather(self.heartbeat_task, return_exceptions=True)
self.heartbeat_task = None
await self._stop_transport()
await self._close_managed_subprocess()
async def make_connection_failed_callback(
ctrl: ws_client_controller.WebSocketClientController,
exc: Exception = None,
) -> None:
if exc is not None:
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')
else:
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}), trying to reconnect...')
await self.runtime_disconnect_callback(self)
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=make_connection_failed_callback,
additional_headers=control_headers,
)
task = self.ctrl.run(new_connection_callback)
elif platform.get_platform() == 'win32':
# Due to Windows's lack of supports for both stdio and subprocess:
# See also: https://docs.python.org/zh-cn/3.13/library/asyncio-platforms.html
# We have to launch runtime via cmd but communicate via ws.
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
control_headers = self._control_headers(allow_generate=True)
await self._start_runtime_subprocess(
'-m',
'langbot_plugin.cli.__init__',
'rt',
env_overrides={PLUGIN_RUNTIME_CONTROL_TOKEN_ENV: self._control_token},
)
ws_url = 'ws://localhost:5400/control/ws'
async def make_connection_failed_callback(
ctrl: ws_client_controller.WebSocketClientController,
exc: Exception = None,
) -> None:
if exc is not None:
self.ap.logger.error(f'(windows) Failed to connect to plugin runtime({ws_url}): {exc}')
else:
self.ap.logger.error(
f'(windows) Failed to connect to plugin runtime({ws_url}), trying to reconnect...'
)
await self.runtime_disconnect_callback(self)
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=make_connection_failed_callback,
additional_headers=control_headers,
)
task = self.ctrl.run(new_connection_callback)
else: # stdio
self.ap.logger.info('use stdio to connect to plugin runtime')
# cmd: lbp rt -s
python_path = sys.executable
env = os.environ.copy()
self.ctrl = stdio_client_controller.StdioClientController(
command=python_path,
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
env=env,
)
task = self.ctrl.run(new_connection_callback)
if self.heartbeat_task is None:
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
asyncio.create_task(task)
async def initialize_plugins(self):
pass
@@ -307,6 +335,48 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
async def ping_plugin_runtime(self):
return await self._runtime_handler().ping()
async def require_workspace_context(self, context: TenantContext) -> ExecutionContext:
"""Fence an HTTP/runtime caller to this connector's one Workspace.
A Plugin Runtime connection is deliberately not a cross-Workspace
router. Calls from another Workspace therefore look like an absent
resource instead of being forwarded to the bound Runtime.
"""
workspace_uuid = require_workspace_uuid(context)
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
generation = getattr(context, 'placement_generation', None)
if not instance_uuid or isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
raise WorkspaceNotFoundError('Plugin resource not found')
binding = await self.ap.workspace_service.get_execution_binding(
workspace_uuid,
expected_generation=generation,
)
if binding.instance_uuid != instance_uuid:
raise WorkspaceNotFoundError('Plugin resource not found')
execution_context = ExecutionContext(
instance_uuid=instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=generation,
trigger_principal=getattr(context, 'principal', None),
)
if not self.is_enable_plugin:
return execution_context
if not hasattr(self, 'handler'):
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
bound_context = self.handler.require_bound_action_context().without_installation()
if (
bound_context.instance_uuid != instance_uuid
or bound_context.workspace_uuid != workspace_uuid
or bound_context.placement_generation != generation
):
raise WorkspaceNotFoundError('Plugin resource not found')
return execution_context
def _inspect_plugin_package(
self,
file_bytes: bytes,
@@ -345,6 +415,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
async def _install_mcp_from_marketplace(
self,
execution_context: ExecutionContext,
mcp_data: dict[str, Any],
task_context: taskmgr.TaskContext | None = None,
):
@@ -357,9 +428,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
for ``http``/``sse`` it preserves ``url``/``headers``/``timeout``/
``ssereadtimeout``.
"""
from ..entity.persistence import mcp as persistence_mcp
import uuid
mode = mcp_data.get('mode') or 'stdio'
extra_args = mcp_data.get('extra_args') or {}
# The MCP transport selection was simplified to two modes: 'stdio'
@@ -377,18 +445,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
# Use __ instead of / to avoid URL routing issues with slashes
name = f'{mcp_data.get("author", "")}__{mcp_data.get("name", "")}'
# Check if MCP server already exists
existing = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == name)
)
if existing.scalar_one_or_none():
existing = await self.ap.mcp_service.get_mcp_server_by_name(execution_context, name)
if existing is not None:
self.ap.logger.info(f'MCP server {name} already exists, skipping installation')
return
# Create MCP server record
server_uuid = str(uuid.uuid4())
server_data = {
'uuid': server_uuid,
'name': name,
'enable': True,
'mode': mode,
@@ -396,23 +458,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
'readme': readme,
}
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
# Start the MCP server
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
)
server_entity = result.first()
if server_entity:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
if self.ap.tool_mgr.mcp_tool_loader:
mcp_task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(mcp_task)
await self.ap.mcp_service.create_mcp_server(execution_context, server_data)
self.ap.logger.info(f'Installed MCP server {name} from marketplace')
async def _install_skill_from_zip(
self,
execution_context: ExecutionContext,
file_bytes: bytes,
filename: str,
task_context: taskmgr.TaskContext | None = None,
@@ -426,6 +478,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
# Install from ZIP using skill service
result = await skill_service.install_from_zip_upload(
execution_context,
file_bytes=file_bytes,
filename=filename + '.zip',
)
@@ -494,6 +547,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_name = install_info.get('plugin_name')
if install_source == PluginInstallSource.MARKETPLACE:
action_context = self.handler.require_bound_action_context()
execution_context = ExecutionContext(
instance_uuid=action_context.instance_uuid,
workspace_uuid=action_context.workspace_uuid,
placement_generation=action_context.placement_generation,
)
# Handle marketplace plugin/mcp/skill installation
plugin_author = install_info.get('plugin_author', '')
plugin_name = install_info.get('plugin_name', '')
@@ -511,7 +570,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
self.ap.logger.info(f'Installing MCP from marketplace: {plugin_author}/{plugin_name}')
if task_context:
task_context.set_current_action('installing mcp server')
await self._install_mcp_from_marketplace(mcp_data, task_context)
await self._install_mcp_from_marketplace(execution_context, mcp_data, task_context)
# Best-effort install report (bumps marketplace install_count).
try:
await client.post(
@@ -554,7 +613,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
self.ap.logger.info(f'Downloaded skill ZIP ({file_size} bytes)')
# Install skill from ZIP using skill service
await self._install_skill_from_zip(file_bytes, f'{plugin_author}-{plugin_name}', task_context)
await self._install_skill_from_zip(
execution_context,
file_bytes,
f'{plugin_author}-{plugin_name}',
task_context,
)
return
elif skill_resp.status_code == 404:
# Try plugin endpoint - get versions and download
@@ -759,6 +823,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
# Fetch all timestamps in a single query using OR conditions
if plugin_ids:
action_context = self.handler.require_bound_action_context()
conditions = [
sqlalchemy.and_(
persistence_plugin.PluginSetting.plugin_author == author,
@@ -772,7 +837,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
persistence_plugin.PluginSetting.plugin_author,
persistence_plugin.PluginSetting.plugin_name,
persistence_plugin.PluginSetting.created_at,
).where(sqlalchemy.or_(*conditions))
)
.where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(sqlalchemy.or_(*conditions))
)
for row in result:
@@ -896,16 +963,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
session: provider_session.Session,
query_id: int,
bound_plugins: list[str] | None = None,
query_uuid: str | None = None,
) -> dict[str, Any]:
if not self.is_enable_plugin:
return {'error': 'Tool not found: plugin system is disabled'}
# Pass include_plugins to runtime for validation
if not self._runtime_available():
return {'error': 'Plugin runtime is temporarily unavailable'}
return await self._runtime_handler().call_tool(
tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
return await self.handler.call_tool(
tool_name,
parameters,
session.model_dump(serialize_as_any=True),
query_id,
query_uuid=query_uuid,
include_plugins=bound_plugins,
)
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
File diff suppressed because it is too large Load Diff
+422 -305
View File
@@ -1,40 +1,55 @@
from __future__ import annotations
import asyncio
import sqlalchemy
import traceback
from typing import TypeVar
from . import requester
import sqlalchemy
from ...api.http.context import (
ExecutionContext,
PrincipalContext,
PrincipalType,
RequestContext,
)
from ...api.http.service.tenant import TenantContext, require_workspace_uuid
from ...core import app
from ...discover import engine
from . import token
from ...entity.persistence import model as persistence_model
from ...entity.errors import provider as provider_errors
from ...entity.persistence import model as persistence_model
from ...workspace.entities import WorkspaceExecutionBinding
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError
from . import requester, token
_CacheKey = tuple[str, str, int, str]
_ModelEntity = TypeVar(
'_ModelEntity',
persistence_model.LLMModel,
persistence_model.EmbeddingModel,
persistence_model.RerankModel,
)
class ModelManager:
"""Model manager"""
"""Workspace-scoped runtime provider and model cache."""
ap: app.Application
provider_dict: dict[str, requester.RuntimeProvider]
"""运行时模型提供商字典, uuid -> RuntimeProvider"""
llm_models: list[requester.RuntimeLLMModel]
embedding_models: list[requester.RuntimeEmbeddingModel]
rerank_models: list[requester.RuntimeRerankModel]
provider_dict: dict[_CacheKey, requester.RuntimeProvider]
llm_model_dict: dict[_CacheKey, requester.RuntimeLLMModel]
embedding_model_dict: dict[_CacheKey, requester.RuntimeEmbeddingModel]
rerank_model_dict: dict[_CacheKey, requester.RuntimeRerankModel]
requester_components: list[engine.Component]
requester_dict: dict[str, type[requester.ProviderAPIRequester]]
def __init__(self, ap: app.Application):
self.ap = ap
self.llm_models = []
self.embedding_models = []
self.rerank_models = []
self.provider_dict = {}
self.llm_model_dict = {}
self.embedding_model_dict = {}
self.rerank_model_dict = {}
self.requester_components = []
self.requester_dict = {}
@@ -60,12 +75,75 @@ class ModelManager:
return litellm_provider
return None
async def initialize(self):
@staticmethod
def _context_from_binding(
binding: WorkspaceExecutionBinding,
*,
trigger_principal: PrincipalContext | None = None,
) -> ExecutionContext:
return ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
trigger_principal=trigger_principal,
)
@staticmethod
def _cache_key(context: ExecutionContext, resource_uuid: str) -> _CacheKey:
return (
context.instance_uuid,
context.workspace_uuid,
context.placement_generation,
resource_uuid,
)
@staticmethod
def _ensure_same_scope(
expected: ExecutionContext,
actual: ExecutionContext,
*,
resource: str,
) -> None:
if (
actual.instance_uuid != expected.instance_uuid
or actual.workspace_uuid != expected.workspace_uuid
or actual.placement_generation != expected.placement_generation
):
raise WorkspaceInvariantError(f'{resource} runtime belongs to another Workspace execution scope')
@staticmethod
def _ensure_entity_workspace(entity: object, context: ExecutionContext, *, resource: str) -> None:
workspace_uuid = getattr(entity, 'workspace_uuid', None)
if workspace_uuid != context.workspace_uuid:
raise WorkspaceInvariantError(f'{resource} belongs to another Workspace')
async def resolve_execution_context(self, context: TenantContext) -> ExecutionContext:
"""Resolve and fence-check an explicit tenant context for runtime access."""
workspace_uuid = require_workspace_uuid(context)
expected_generation = None
supplied_instance_uuid = None
trigger_principal = None
if isinstance(context, (RequestContext, ExecutionContext)):
expected_generation = context.placement_generation
supplied_instance_uuid = context.instance_uuid
trigger_principal = context.principal if isinstance(context, RequestContext) else context.trigger_principal
binding = await self.ap.workspace_service.get_execution_binding(
workspace_uuid,
expected_generation=expected_generation,
)
if supplied_instance_uuid is not None and supplied_instance_uuid != binding.instance_uuid:
raise WorkspaceInvariantError('Runtime context belongs to another LangBot instance')
return self._context_from_binding(binding, trigger_principal=trigger_principal)
async def initialize(self) -> None:
self.requester_components = self.ap.discover.get_components_by_kind('LLMAPIRequester')
requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}
for component in self.requester_components:
# Skip components that use litellm_provider (they will use litellmchat.py instead)
litellm_provider = self._get_litellm_provider_from_manifest(component)
if litellm_provider:
self.ap.logger.debug(
@@ -76,133 +154,151 @@ class ModelManager:
requester_dict[component.metadata.name] = component.get_python_component_class()
self.requester_dict = requester_dict
await self.load_models_from_db()
# Check if space models service is disabled
space_config = self.ap.instance_config.data.get('space', {})
if space_config.get('disable_models_service', False):
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
return
# Space model synchronization is a legacy OSS-singleton facility. A
# cloud instance must receive tenant model projections from its control
# plane and must never infer one Workspace for this global operation.
try:
binding = await self.ap.workspace_service.get_local_execution_binding()
except WorkspaceError as exc:
self.ap.logger.info(f'Skipping LangBot Space model sync outside an OSS local Workspace: {exc}')
return
sync_context = self._context_from_binding(
binding,
trigger_principal=PrincipalContext(principal_type=PrincipalType.SYSTEM),
)
sync_timeout = space_config.get('models_sync_timeout')
try:
if sync_timeout:
await asyncio.wait_for(
self.sync_new_models_from_space(),
self.sync_new_models_from_space(sync_context),
timeout=float(sync_timeout),
)
else:
await self.sync_new_models_from_space()
await self.sync_new_models_from_space(sync_context)
except asyncio.TimeoutError:
self.ap.logger.warning(f'LangBot Space model sync timed out after {sync_timeout}s, skipping startup sync.')
except Exception as e:
except Exception as exc:
self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')
self.ap.logger.warning(f' - Error: {e}')
self.ap.logger.warning(f' - Error: {exc}')
async def load_models_from_db(self) -> None:
"""Load every active projected Workspace into isolated runtime caches."""
async def load_models_from_db(self):
"""Load models from database"""
self.ap.logger.info('Loading models from db...')
self.llm_models = []
self.embedding_models = []
self.rerank_models = []
self.provider_dict = {}
self.llm_model_dict = {}
self.embedding_model_dict = {}
self.rerank_model_dict = {}
contexts: dict[str, ExecutionContext] = {}
async def context_for(workspace_uuid: str | None) -> ExecutionContext:
if not workspace_uuid:
raise WorkspaceInvariantError('Runtime model resource has no Workspace')
cached = contexts.get(workspace_uuid)
if cached is not None:
return cached
binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid)
resolved = self._context_from_binding(
binding,
trigger_principal=PrincipalContext(principal_type=PrincipalType.SYSTEM),
)
contexts[workspace_uuid] = resolved
return resolved
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
)
for provider in providers_result.all():
for provider_entity in providers_result.all():
try:
runtime_provider = await self.load_provider(provider)
self.provider_dict[provider.uuid] = runtime_provider
except provider_errors.RequesterNotFoundError as e:
self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping provider {provider.uuid}')
continue
except Exception as e:
self.ap.logger.error(f'Failed to load provider {provider.uuid}: {e}\n{traceback.format_exc()}')
context = await context_for(provider_entity.workspace_uuid)
runtime_provider = await self._build_provider(context, provider_entity)
self.provider_dict[self._cache_key(context, provider_entity.uuid)] = runtime_provider
except provider_errors.RequesterNotFoundError as exc:
self.ap.logger.warning(
f'Requester {exc.requester_name} not found, skipping provider {provider_entity.uuid}'
)
except Exception as exc:
self.ap.logger.error(f'Failed to load provider {provider_entity.uuid}: {exc}\n{traceback.format_exc()}')
# Load LLM models
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
llm_models = result.all()
for llm_model in llm_models:
try:
provider = self.provider_dict.get(llm_model.provider_uuid)
if provider is None:
self.ap.logger.warning(f'Provider {llm_model.provider_uuid} not found for model {llm_model.uuid}')
continue
runtime_llm_model = await self.load_llm_model_with_provider(llm_model, provider)
self.llm_models.append(runtime_llm_model)
except Exception as e:
self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}')
await self._load_model_kind(
persistence_model.LLMModel,
self.llm_model_dict,
self._build_llm_model,
context_for,
)
await self._load_model_kind(
persistence_model.EmbeddingModel,
self.embedding_model_dict,
self._build_embedding_model,
context_for,
)
await self._load_model_kind(
persistence_model.RerankModel,
self.rerank_model_dict,
self._build_rerank_model,
context_for,
)
# Load embedding models
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
embedding_models = result.all()
for embedding_model in embedding_models:
async def _load_model_kind(self, entity_type, cache: dict, builder, context_for) -> None:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(entity_type))
for model_entity in result.all():
try:
provider = self.provider_dict.get(embedding_model.provider_uuid)
context = await context_for(model_entity.workspace_uuid)
provider = self.provider_dict.get(self._cache_key(context, model_entity.provider_uuid))
if provider is None:
self.ap.logger.warning(
f'Provider {embedding_model.provider_uuid} not found for model {embedding_model.uuid}'
f'Provider {model_entity.provider_uuid} not found for model {model_entity.uuid}'
)
continue
runtime_embedding_model = await self.load_embedding_model_with_provider(embedding_model, provider)
self.embedding_models.append(runtime_embedding_model)
except Exception as e:
self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
runtime_model = builder(context, model_entity, provider)
cache[self._cache_key(context, model_entity.uuid)] = runtime_model
except Exception as exc:
self.ap.logger.error(f'Failed to load model {model_entity.uuid}: {exc}\n{traceback.format_exc()}')
# Load rerank models
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
rerank_models = result.all()
for rerank_model in rerank_models:
try:
provider = self.provider_dict.get(rerank_model.provider_uuid)
if provider is None:
self.ap.logger.warning(
f'Provider {rerank_model.provider_uuid} not found for model {rerank_model.uuid}'
)
continue
runtime_rerank_model = await self.load_rerank_model_with_provider(rerank_model, provider)
self.rerank_models.append(runtime_rerank_model)
except Exception as e:
self.ap.logger.error(f'Failed to load model {rerank_model.uuid}: {e}\n{traceback.format_exc()}')
async def sync_new_models_from_space(self, context: ExecutionContext) -> None:
"""Sync legacy Space models for the explicitly selected OSS Workspace."""
async def sync_new_models_from_space(self):
"""Sync models from Space"""
space_model_provider = await self.ap.persistence_mgr.execute_async(
context = await self.resolve_execution_context(context)
await self.ap.workspace_service.get_local_execution_binding(
context.workspace_uuid,
expected_generation=context.placement_generation,
)
space_model_provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.requester == 'space-chat-completions'
persistence_model.ModelProvider.workspace_uuid == context.workspace_uuid,
persistence_model.ModelProvider.requester == 'space-chat-completions',
)
)
result = space_model_provider.first()
if result is None:
space_model_provider = space_model_provider_result.first()
if space_model_provider is None:
raise provider_errors.ProviderNotFoundError('LangBot Models')
space_model_provider = result
# get the latest models from space
space_models = await self.ap.space_service.get_models()
# Index existing models by uuid. Space reuses a model's uuid across
# renames / re-specs (e.g. the uuid that used to be ``claude-opus-4-6``
# may later become ``claude-opus-4-7``). So for Space-managed models we
# upsert: create when the uuid is new, otherwise update name/abilities/
# ranking to track Space. Models owned by other providers are never
# touched, even on an (unexpected) uuid collision.
existing_llm_models = {m['uuid']: m for m in await self.ap.llm_model_service.get_llm_models()}
existing_llm_models = {
model['uuid']: model
for model in await self.ap.llm_model_service.get_llm_models(context, include_secret=True)
}
existing_embedding_models = {
m['uuid']: m for m in await self.ap.embedding_models_service.get_embedding_models()
model['uuid']: model
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
}
created = 0
updated = 0
for space_model in space_models:
if space_model.category == 'chat':
existing = existing_llm_models.get(space_model.uuid)
if existing is None:
# model will be automatically loaded
await self.ap.llm_model_service.create_llm_model(
context,
{
'uuid': space_model.uuid,
'name': space_model.model_id,
@@ -227,14 +323,14 @@ class ModelManager:
or list(existing.get('abilities') or []) != list(desired['abilities'])
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
await self.ap.llm_model_service.update_llm_model(space_model.uuid, dict(desired))
await self.ap.llm_model_service.update_llm_model(context, space_model.uuid, dict(desired))
updated += 1
elif space_model.category == 'embedding':
existing = existing_embedding_models.get(space_model.uuid)
if existing is None:
# model will be automatically loaded
await self.ap.embedding_models_service.create_embedding_model(
context,
{
'uuid': space_model.uuid,
'name': space_model.model_id,
@@ -255,7 +351,11 @@ class ModelManager:
existing.get('name') != desired['name']
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
await self.ap.embedding_models_service.update_embedding_model(space_model.uuid, dict(desired))
await self.ap.embedding_models_service.update_embedding_model(
context,
space_model.uuid,
dict(desired),
)
updated += 1
if created or updated:
@@ -263,313 +363,330 @@ class ModelManager:
async def init_temporary_runtime_llm_model(
self,
context: TenantContext,
model_info: dict,
) -> requester.RuntimeLLMModel:
"""Initialize runtime LLM model from dict (for testing)"""
provider_info = model_info.get('provider', {})
runtime_provider = await self.load_provider(provider_info)
runtime_llm_model = requester.RuntimeLLMModel(
model_entity=persistence_model.LLMModel(
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid='',
abilities=model_info.get('abilities', []),
context_length=model_info.get('context_length'),
extra_args=model_info.get('extra_args', {}),
),
provider=runtime_provider,
execution_context = await self.resolve_execution_context(context)
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
runtime_provider = await self._build_provider(
execution_context,
persistence_model.ModelProvider(**provider_info),
)
return runtime_llm_model
model_entity = persistence_model.LLMModel(
workspace_uuid=execution_context.workspace_uuid,
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid=runtime_provider.provider_entity.uuid,
abilities=model_info.get('abilities', []),
context_length=model_info.get('context_length'),
extra_args=model_info.get('extra_args', {}),
)
return self._build_llm_model(execution_context, model_entity, runtime_provider)
async def init_temporary_runtime_embedding_model(
self,
context: TenantContext,
model_info: dict,
) -> requester.RuntimeEmbeddingModel:
"""Initialize runtime embedding model from dict (for testing)"""
provider_info = model_info.get('provider', {})
runtime_provider = await self.load_provider(provider_info)
runtime_embedding_model = requester.RuntimeEmbeddingModel(
model_entity=persistence_model.EmbeddingModel(
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid='',
extra_args=model_info.get('extra_args', {}),
),
provider=runtime_provider,
execution_context = await self.resolve_execution_context(context)
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
runtime_provider = await self._build_provider(
execution_context,
persistence_model.ModelProvider(**provider_info),
)
return runtime_embedding_model
model_entity = persistence_model.EmbeddingModel(
workspace_uuid=execution_context.workspace_uuid,
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid=runtime_provider.provider_entity.uuid,
extra_args=model_info.get('extra_args', {}),
)
return self._build_embedding_model(execution_context, model_entity, runtime_provider)
async def init_temporary_runtime_rerank_model(
self,
context: TenantContext,
model_info: dict,
) -> requester.RuntimeRerankModel:
"""Initialize runtime rerank model from dict (for testing)"""
provider_info = model_info.get('provider', {})
runtime_provider = await self.load_provider(provider_info)
runtime_rerank_model = requester.RuntimeRerankModel(
model_entity=persistence_model.RerankModel(
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid='',
extra_args=model_info.get('extra_args', {}),
),
provider=runtime_provider,
execution_context = await self.resolve_execution_context(context)
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
runtime_provider = await self._build_provider(
execution_context,
persistence_model.ModelProvider(**provider_info),
)
model_entity = persistence_model.RerankModel(
workspace_uuid=execution_context.workspace_uuid,
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid=runtime_provider.provider_entity.uuid,
extra_args=model_info.get('extra_args', {}),
)
return self._build_rerank_model(execution_context, model_entity, runtime_provider)
return runtime_rerank_model
async def load_provider(
self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict
) -> requester.RuntimeProvider:
"""Load provider from dict"""
@staticmethod
def _coerce_provider(
provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict,
context: ExecutionContext,
) -> persistence_model.ModelProvider:
if isinstance(provider_info, sqlalchemy.Row):
provider_entity = persistence_model.ModelProvider(**provider_info._mapping)
elif isinstance(provider_info, dict):
provider_entity = persistence_model.ModelProvider(**provider_info)
provider_entity = persistence_model.ModelProvider(
**{**provider_info, 'workspace_uuid': context.workspace_uuid}
)
else:
provider_entity = provider_info
ModelManager._ensure_entity_workspace(provider_entity, context, resource='Provider')
return provider_entity
# Get requester manifest to check for litellm_provider
async def _build_provider(
self,
context: ExecutionContext,
provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict,
) -> requester.RuntimeProvider:
provider_entity = self._coerce_provider(provider_info, context)
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest)
# Build config from base_url
config = {'base_url': provider_entity.base_url}
# Check if requester manifest specifies litellm_provider
if litellm_provider:
from .requesters import litellmchat
# Use unified LiteLLMRequester with provider prefix
# Map litellm_provider (YAML spec) to custom_llm_provider (config)
config['custom_llm_provider'] = litellm_provider
requester_inst = litellmchat.LiteLLMRequester(
ap=self.ap,
config=config,
)
requester_inst = litellmchat.LiteLLMRequester(ap=self.ap, config=config)
self.ap.logger.debug(
f'Using LiteLLMRequester for {provider_entity.requester} '
f'with custom_llm_provider={config["custom_llm_provider"]}'
)
else:
# Use original requester class (for backward compatibility)
if provider_entity.requester not in self.requester_dict:
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
requester_inst = self.requester_dict[provider_entity.requester](
ap=self.ap,
config=config,
)
requester_inst = self.requester_dict[provider_entity.requester](ap=self.ap, config=config)
await requester_inst.initialize()
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
provider = requester.RuntimeProvider(
return requester.RuntimeProvider(
execution_context=context,
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
)
async def load_provider(
self,
context: TenantContext,
provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict,
) -> requester.RuntimeProvider:
execution_context = await self.resolve_execution_context(context)
return await self._build_provider(execution_context, provider_info)
async def cache_provider(self, context: TenantContext, provider: requester.RuntimeProvider) -> None:
execution_context = await self.resolve_execution_context(context)
self._ensure_same_scope(execution_context, provider.execution_context, resource='Provider')
self._ensure_entity_workspace(provider.provider_entity, execution_context, resource='Provider')
self.provider_dict[self._cache_key(execution_context, provider.provider_entity.uuid)] = provider
async def get_provider_by_uuid(
self,
context: TenantContext,
provider_uuid: str,
) -> requester.RuntimeProvider:
execution_context = await self.resolve_execution_context(context)
provider = self.provider_dict.get(self._cache_key(execution_context, provider_uuid))
if provider is None:
raise ValueError(f'Model provider {provider_uuid} not found')
self._ensure_same_scope(execution_context, provider.execution_context, resource='Provider')
return provider
async def remove_provider(self, provider_uuid: str):
"""Remove provider
async def remove_provider(self, context: TenantContext, provider_uuid: str) -> None:
execution_context = await self.resolve_execution_context(context)
self.provider_dict.pop(self._cache_key(execution_context, provider_uuid), None)
This method will not consider the models using this provider,
because the models should be removed by the caller.
"""
del self.provider_dict[provider_uuid]
async def reload_provider(self, provider_uuid: str):
"""Reload provider"""
provider_entity = await self.ap.persistence_mgr.execute_async(
async def reload_provider(self, context: TenantContext, provider_uuid: str) -> None:
execution_context = await self.resolve_execution_context(context)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
persistence_model.ModelProvider.workspace_uuid == execution_context.workspace_uuid,
persistence_model.ModelProvider.uuid == provider_uuid,
)
)
provider_entity = provider_entity.first()
provider_entity = result.first()
if provider_entity is None:
raise provider_errors.ProviderNotFoundError(provider_uuid)
new_runtime_provider = await self.load_provider(provider_entity)
new_provider = await self._build_provider(execution_context, provider_entity)
cache_prefix = self._cache_key(execution_context, '')[:3]
for cache in (self.llm_model_dict, self.embedding_model_dict, self.rerank_model_dict):
for key, model in cache.items():
if key[:3] == cache_prefix and model.provider.provider_entity.uuid == provider_uuid:
model.provider = new_provider
self.provider_dict[self._cache_key(execution_context, provider_uuid)] = new_provider
# update refs in runtime models
for model in self.llm_models:
if model.provider.provider_entity.uuid == provider_uuid:
model.provider = new_runtime_provider
for model in self.embedding_models:
if model.provider.provider_entity.uuid == provider_uuid:
model.provider = new_runtime_provider
for model in self.rerank_models:
if model.provider.provider_entity.uuid == provider_uuid:
model.provider = new_runtime_provider
@staticmethod
def _coerce_model(model_info: _ModelEntity | sqlalchemy.Row, entity_type: type[_ModelEntity]) -> _ModelEntity:
if isinstance(model_info, sqlalchemy.Row):
return entity_type(**model_info._mapping)
return model_info
# update ref in provider dict
self.provider_dict[provider_uuid] = new_runtime_provider
async def load_llm_model_with_provider(
def _validate_model_provider(
self,
context: ExecutionContext,
model_entity: _ModelEntity,
provider: requester.RuntimeProvider,
) -> None:
self._ensure_entity_workspace(model_entity, context, resource='Model')
self._ensure_same_scope(context, provider.execution_context, resource='Provider')
if model_entity.provider_uuid != provider.provider_entity.uuid:
raise WorkspaceInvariantError('Model references a different provider')
def _build_llm_model(
self,
context: ExecutionContext,
model_info: persistence_model.LLMModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeLLMModel:
"""Load LLM model with provider info"""
if isinstance(model_info, sqlalchemy.Row):
model_info = persistence_model.LLMModel(**model_info._mapping)
runtime_llm_model = requester.RuntimeLLMModel(
model_entity=model_info,
model_entity = self._coerce_model(model_info, persistence_model.LLMModel)
self._validate_model_provider(context, model_entity, provider)
return requester.RuntimeLLMModel(
execution_context=context,
model_entity=model_entity,
provider=provider,
)
return runtime_llm_model
async def load_embedding_model_with_provider(
def _build_embedding_model(
self,
context: ExecutionContext,
model_info: persistence_model.EmbeddingModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeEmbeddingModel:
"""Load embedding model with provider info"""
if isinstance(model_info, sqlalchemy.Row):
model_info = persistence_model.EmbeddingModel(**model_info._mapping)
runtime_embedding_model = requester.RuntimeEmbeddingModel(
model_entity=model_info,
model_entity = self._coerce_model(model_info, persistence_model.EmbeddingModel)
self._validate_model_provider(context, model_entity, provider)
return requester.RuntimeEmbeddingModel(
execution_context=context,
model_entity=model_entity,
provider=provider,
)
return runtime_embedding_model
async def load_rerank_model_with_provider(
def _build_rerank_model(
self,
context: ExecutionContext,
model_info: persistence_model.RerankModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeRerankModel:
"""Load rerank model with provider info"""
if isinstance(model_info, sqlalchemy.Row):
model_info = persistence_model.RerankModel(**model_info._mapping)
runtime_rerank_model = requester.RuntimeRerankModel(
model_entity=model_info,
model_entity = self._coerce_model(model_info, persistence_model.RerankModel)
self._validate_model_provider(context, model_entity, provider)
return requester.RuntimeRerankModel(
execution_context=context,
model_entity=model_entity,
provider=provider,
)
return runtime_rerank_model
async def load_llm_model_with_provider(
self,
context: TenantContext,
model_info: persistence_model.LLMModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeLLMModel:
execution_context = await self.resolve_execution_context(context)
return self._build_llm_model(execution_context, model_info, provider)
async def load_llm_model(self, model_info: dict):
"""Load LLM model from dict (with provider info)"""
provider_info = model_info.get('provider', {})
if not provider_info:
raise ValueError('Provider info is required')
async def load_embedding_model_with_provider(
self,
context: TenantContext,
model_info: persistence_model.EmbeddingModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeEmbeddingModel:
execution_context = await self.resolve_execution_context(context)
return self._build_embedding_model(execution_context, model_info, provider)
model_entity = persistence_model.LLMModel(
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid=model_info.get('provider_uuid', ''),
abilities=model_info.get('abilities', []),
context_length=model_info.get('context_length'),
extra_args=model_info.get('extra_args', {}),
)
async def load_rerank_model_with_provider(
self,
context: TenantContext,
model_info: persistence_model.RerankModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeRerankModel:
execution_context = await self.resolve_execution_context(context)
return self._build_rerank_model(execution_context, model_info, provider)
provider_entity = persistence_model.ModelProvider(
uuid=provider_info.get('uuid', ''),
name=provider_info.get('name', ''),
requester=provider_info.get('requester', ''),
base_url=provider_info.get('base_url', ''),
api_keys=provider_info.get('api_keys', []),
)
async def cache_llm_model(self, context: TenantContext, model: requester.RuntimeLLMModel) -> None:
execution_context = await self.resolve_execution_context(context)
self._ensure_same_scope(execution_context, model.execution_context, resource='LLM model')
self.llm_model_dict[self._cache_key(execution_context, model.model_entity.uuid)] = model
await self.load_llm_model_with_provider(model_entity, provider_entity)
async def cache_embedding_model(
self,
context: TenantContext,
model: requester.RuntimeEmbeddingModel,
) -> None:
execution_context = await self.resolve_execution_context(context)
self._ensure_same_scope(execution_context, model.execution_context, resource='Embedding model')
self.embedding_model_dict[self._cache_key(execution_context, model.model_entity.uuid)] = model
async def load_embedding_model(self, model_info: dict):
"""Load embedding model from dict (with provider info)"""
provider_info = model_info.get('provider', {})
if not provider_info:
raise ValueError('Provider info is required')
async def cache_rerank_model(self, context: TenantContext, model: requester.RuntimeRerankModel) -> None:
execution_context = await self.resolve_execution_context(context)
self._ensure_same_scope(execution_context, model.execution_context, resource='Rerank model')
self.rerank_model_dict[self._cache_key(execution_context, model.model_entity.uuid)] = model
model_entity = persistence_model.EmbeddingModel(
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid=model_info.get('provider_uuid', ''),
extra_args=model_info.get('extra_args', {}),
)
async def get_model_by_uuid(self, context: TenantContext, model_uuid: str) -> requester.RuntimeLLMModel:
execution_context = await self.resolve_execution_context(context)
model = self.llm_model_dict.get(self._cache_key(execution_context, model_uuid))
if model is None:
raise ValueError(f'LLM model {model_uuid} not found')
self._ensure_same_scope(execution_context, model.execution_context, resource='LLM model')
return model
provider_entity = persistence_model.ModelProvider(
uuid=provider_info.get('uuid', ''),
name=provider_info.get('name', ''),
requester=provider_info.get('requester', ''),
base_url=provider_info.get('base_url', ''),
api_keys=provider_info.get('api_keys', []),
)
async def get_embedding_model_by_uuid(
self,
context: TenantContext,
model_uuid: str,
) -> requester.RuntimeEmbeddingModel:
execution_context = await self.resolve_execution_context(context)
model = self.embedding_model_dict.get(self._cache_key(execution_context, model_uuid))
if model is None:
raise ValueError(f'Embedding model {model_uuid} not found')
self._ensure_same_scope(execution_context, model.execution_context, resource='Embedding model')
return model
await self.load_embedding_model_with_provider(model_entity, provider_entity)
async def get_rerank_model_by_uuid(
self,
context: TenantContext,
model_uuid: str,
) -> requester.RuntimeRerankModel:
execution_context = await self.resolve_execution_context(context)
model = self.rerank_model_dict.get(self._cache_key(execution_context, model_uuid))
if model is None:
raise ValueError(f'Rerank model {model_uuid} not found')
self._ensure_same_scope(execution_context, model.execution_context, resource='Rerank model')
return model
async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
"""Get LLM model by uuid"""
for model in self.llm_models:
if model.model_entity.uuid == uuid:
return model
raise ValueError(f'LLM model {uuid} not found')
async def remove_llm_model(self, context: TenantContext, model_uuid: str) -> None:
execution_context = await self.resolve_execution_context(context)
self.llm_model_dict.pop(self._cache_key(execution_context, model_uuid), None)
async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
"""Get embedding model by uuid"""
for model in self.embedding_models:
if model.model_entity.uuid == uuid:
return model
raise ValueError(f'Embedding model {uuid} not found')
async def remove_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
execution_context = await self.resolve_execution_context(context)
self.embedding_model_dict.pop(self._cache_key(execution_context, model_uuid), None)
async def get_rerank_model_by_uuid(self, uuid: str) -> requester.RuntimeRerankModel:
"""Get rerank model by uuid"""
for model in self.rerank_models:
if model.model_entity.uuid == uuid:
return model
raise ValueError(f'Rerank model {uuid} not found')
async def remove_llm_model(self, model_uuid: str):
"""Remove LLM model"""
for model in self.llm_models:
if model.model_entity.uuid == model_uuid:
self.llm_models.remove(model)
return
async def remove_embedding_model(self, model_uuid: str):
"""Remove embedding model"""
for model in self.embedding_models:
if model.model_entity.uuid == model_uuid:
self.embedding_models.remove(model)
return
async def remove_rerank_model(self, model_uuid: str):
"""Remove rerank model"""
for model in self.rerank_models:
if model.model_entity.uuid == model_uuid:
self.rerank_models.remove(model)
return
async def remove_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
execution_context = await self.resolve_execution_context(context)
self.rerank_model_dict.pop(self._cache_key(execution_context, model_uuid), None)
def get_available_requesters_info(self, model_type: str) -> list[dict]:
"""Get all available requesters"""
if model_type != '':
if model_type:
return [
component.to_plain_dict()
for component in self.requester_components
if model_type in component.spec['support_type']
]
else:
return [component.to_plain_dict() for component in self.requester_components]
return [component.to_plain_dict() for component in self.requester_components]
def get_available_requester_info_by_name(self, name: str) -> dict | None:
"""Get requester info by name"""
for component in self.requester_components:
if component.metadata.name == name:
return component.to_plain_dict()
return None
def get_available_requester_manifest_by_name(self, name: str) -> engine.Component | None:
"""Get requester manifest by name"""
for component in self.requester_components:
if component.metadata.name == name:
return component
+86 -2
View File
@@ -5,7 +5,9 @@ import typing
import time
from ...core import app
from ...api.http.context import ExecutionContext
from ...entity.persistence import model as persistence_model
from ...workspace.errors import WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from . import token
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -16,6 +18,20 @@ LLM_USAGE_QUERY_VARIABLE = '_llm_usage'
STREAM_USAGE_QUERY_VARIABLE = '_stream_usage'
def _ensure_same_execution_scope(
expected: ExecutionContext,
actual: ExecutionContext,
*,
resource: str,
) -> None:
if (
actual.instance_uuid != expected.instance_uuid
or actual.workspace_uuid != expected.workspace_uuid
or actual.placement_generation != expected.placement_generation
):
raise WorkspaceInvariantError(f'{resource} belongs to another Workspace execution scope')
def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None:
"""Store the latest provider usage on the query for upstream action handlers."""
if query is None or not usage_info:
@@ -39,24 +55,61 @@ class RuntimeProvider:
def __init__(
self,
execution_context: ExecutionContext,
provider_entity: persistence_model.ModelProvider,
token_mgr: token.TokenManager,
requester: ProviderAPIRequester,
):
if provider_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceInvariantError('Provider belongs to another Workspace')
self.execution_context = execution_context
self.provider_entity = provider_entity
self.token_mgr = token_mgr
self.requester = requester
def _validate_invocation(
self,
model: RuntimeLLMModel | RuntimeEmbeddingModel | RuntimeRerankModel,
execution_context: ExecutionContext,
) -> None:
_ensure_same_execution_scope(self.execution_context, execution_context, resource='Provider invocation')
_ensure_same_execution_scope(self.execution_context, model.execution_context, resource='Runtime model')
if model.provider is not self:
raise WorkspaceInvariantError('Runtime model is attached to another provider')
def _resolve_llm_execution_context(
self,
query: pipeline_query.Query | None,
execution_context: ExecutionContext | None,
) -> ExecutionContext:
if query is not None:
from ...pipeline.pool import get_query_execution_context
query_context = get_query_execution_context(query)
if execution_context is not None:
_ensure_same_execution_scope(
query_context,
execution_context,
resource='Explicit LLM invocation context',
)
return query_context
if execution_context is None:
raise WorkspaceInvariantError('LLM invocation requires an ExecutionContext when query is absent')
return execution_context
async def invoke_llm(
self,
query: pipeline_query.Query,
query: pipeline_query.Query | None,
model: RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
execution_context: ExecutionContext | None = None,
) -> provider_message.Message:
"""Bridge method for invoking LLM with monitoring"""
invocation_context = self._resolve_llm_execution_context(query, execution_context)
self._validate_invocation(model, invocation_context)
# Start timing for monitoring
start_time = time.time()
input_tokens = 0
@@ -130,14 +183,17 @@ class RuntimeProvider:
async def invoke_llm_stream(
self,
query: pipeline_query.Query,
query: pipeline_query.Query | None,
model: RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
execution_context: ExecutionContext | None = None,
) -> provider_message.MessageChunk:
"""Bridge method for invoking LLM stream with monitoring"""
invocation_context = self._resolve_llm_execution_context(query, execution_context)
self._validate_invocation(model, invocation_context)
# Start timing for monitoring
start_time = time.time()
status = 'success'
@@ -212,6 +268,8 @@ class RuntimeProvider:
model: RuntimeEmbeddingModel,
input_text: typing.List[str],
extra_args: dict[str, typing.Any] = {},
*,
execution_context: ExecutionContext,
knowledge_base_id: str | None = None,
query_text: str | None = None,
session_id: str | None = None,
@@ -219,6 +277,7 @@ class RuntimeProvider:
call_type: str | None = None,
) -> typing.List[typing.List[float]]:
"""Bridge method for invoking embedding with monitoring"""
self._validate_invocation(model, execution_context)
# Start timing for monitoring
start_time = time.time()
prompt_tokens = 0
@@ -254,6 +313,7 @@ class RuntimeProvider:
try:
await self.requester.ap.monitoring_service.record_embedding_call(
execution_context,
model_name=model.model_entity.name,
prompt_tokens=prompt_tokens,
total_tokens=total_tokens,
@@ -276,8 +336,11 @@ class RuntimeProvider:
query: str,
documents: typing.List[str],
extra_args: dict[str, typing.Any] = {},
*,
execution_context: ExecutionContext,
) -> typing.List[dict]:
"""Bridge method for invoking rerank with monitoring"""
self._validate_invocation(model, execution_context)
start_time = time.time()
status = 'success'
@@ -316,9 +379,16 @@ class RuntimeLLMModel:
def __init__(
self,
execution_context: ExecutionContext,
model_entity: persistence_model.LLMModel,
provider: RuntimeProvider,
):
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='LLM model')
if model_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceInvariantError('LLM model belongs to another Workspace')
if model_entity.provider_uuid != provider.provider_entity.uuid:
raise WorkspaceInvariantError('LLM model references another provider')
self.execution_context = execution_context
self.model_entity = model_entity
self.provider = provider
@@ -334,9 +404,16 @@ class RuntimeEmbeddingModel:
def __init__(
self,
execution_context: ExecutionContext,
model_entity: persistence_model.EmbeddingModel,
provider: RuntimeProvider,
):
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='Embedding model')
if model_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceInvariantError('Embedding model belongs to another Workspace')
if model_entity.provider_uuid != provider.provider_entity.uuid:
raise WorkspaceInvariantError('Embedding model references another provider')
self.execution_context = execution_context
self.model_entity = model_entity
self.provider = provider
@@ -352,9 +429,16 @@ class RuntimeRerankModel:
def __init__(
self,
execution_context: ExecutionContext,
model_entity: persistence_model.RerankModel,
provider: RuntimeProvider,
):
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='Rerank model')
if model_entity.workspace_uuid != execution_context.workspace_uuid:
raise WorkspaceInvariantError('Rerank model belongs to another Workspace')
if model_entity.provider_uuid != provider.provider_entity.uuid:
raise WorkspaceInvariantError('Rerank model references another provider')
self.execution_context = execution_context
self.model_entity = model_entity
self.provider = provider
+12 -7
View File
@@ -22,10 +22,12 @@ from langbot.libs.dify_service_api.v1 import client, errors
import httpx
# Module-level store for paused-workflow form state. The key isolates the bot,
# pipeline, adapter, and launcher; each value holds an insertion-ordered map of
# form_token -> form_data so one conversation can pause multiple workflows.
PendingFormKey = tuple[str, str, str, str, str]
# Module-level store for paused-workflow form state. The key includes the full
# execution scope before the bot, pipeline, adapter, and launcher dimensions;
# each value holds an insertion-ordered map of form_token -> form_data so one
# conversation can pause multiple workflows without crossing Workspaces or
# placement generations.
PendingFormKey = tuple[str, str, int, str, str, str, str, str]
_PENDING_FORMS: dict[PendingFormKey, 'OrderedDict[str, dict[str, typing.Any]]'] = {}
_PENDING_FORM_DEFAULT_TTL = 30 * 60 # 30 minutes safety cap
_STREAM_FORM_PLACEHOLDER = '\u200b'
@@ -48,10 +50,13 @@ def _dify_user_from_query(query: pipeline_query.Query) -> str:
def _session_key_from_query(query: pipeline_query.Query) -> PendingFormKey:
"""Build a process-local pending-form key isolated by bot and pipeline."""
"""Build a process-local pending-form key isolated by execution scope."""
adapter = getattr(query, 'adapter', None)
adapter_type = f'{type(adapter).__module__}.{type(adapter).__qualname__}'
return (
str(getattr(query, 'instance_uuid', '') or ''),
str(getattr(query, 'workspace_uuid', '') or ''),
int(getattr(query, 'placement_generation', 0) or 0),
str(getattr(query, 'bot_uuid', '') or ''),
str(getattr(query, 'pipeline_uuid', '') or ''),
adapter_type,
@@ -74,8 +79,8 @@ def _prune_pending_forms(now: float | None = None) -> None:
def _set_pending_form(session_key: PendingFormKey, form_data: dict[str, typing.Any]) -> None:
_prune_pending_forms()
if isinstance(session_key, tuple) and len(session_key) > 1:
form_data['pipeline_uuid'] = session_key[1]
if isinstance(session_key, tuple) and len(session_key) == 8:
form_data['pipeline_uuid'] = session_key[4]
stored = dict(form_data)
expiration_time = stored.get('expiration_time')
try:
+17 -4
View File
@@ -11,6 +11,7 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.rag.context as rag_context
from ...pipeline.pool import get_query_execution_context
rag_combined_prompt_template = """
The following are relevant context entries retrieved from the knowledge base.
@@ -227,7 +228,10 @@ class LocalAgentRunner(runner.RequestRunner):
# Primary model
if query.use_llm_model_uuid:
try:
primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
primary = await self.ap.model_mgr.get_model_by_uuid(
get_query_execution_context(query),
query.use_llm_model_uuid,
)
candidates.append(primary)
except ValueError:
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
@@ -236,7 +240,10 @@ class LocalAgentRunner(runner.RequestRunner):
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
for fb_uuid in fallback_uuids:
try:
fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
fb_model = await self.ap.model_mgr.get_model_by_uuid(
get_query_execution_context(query),
fb_uuid,
)
candidates.append(fb_model)
except ValueError:
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
@@ -346,12 +353,13 @@ class LocalAgentRunner(runner.RequestRunner):
if kb_uuids and user_message_text:
# only support text for now
all_results: list[rag_context.RetrievalResultEntry] = []
execution_context = get_query_execution_context(query)
kb_engine_plugins: set[str] = set()
# Retrieve from each knowledge base
for kb_uuid in kb_uuids:
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
if not kb:
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
@@ -364,6 +372,7 @@ class LocalAgentRunner(runner.RequestRunner):
kb_engine_plugins.add(engine_plugin_id)
result = await kb.retrieve(
execution_context,
user_message_text,
settings={
'bot_uuid': query.bot_uuid or '',
@@ -398,7 +407,10 @@ class LocalAgentRunner(runner.RequestRunner):
)
if all_results and rerank_model_uuid:
try:
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(
execution_context,
rerank_model_uuid,
)
rerank_top_k = int(local_agent_config.get('rerank-top-k', 5))
doc_texts = []
@@ -411,6 +423,7 @@ class LocalAgentRunner(runner.RequestRunner):
model=rerank_model,
query=user_message_text,
documents=doc_texts_capped,
execution_context=execution_context,
)
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
+76 -3
View File
@@ -1,12 +1,48 @@
from __future__ import annotations
import asyncio
import dataclasses
from ...core import app
from langbot_plugin.api.entities.builtin.provider import message as provider_message, prompt as provider_prompt
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from ...api.http.context import ExecutionContext
from ...core import app
from ...pipeline.pool import (
ExecutionContextMismatchError,
ExecutionContextRequiredError,
bind_execution_context,
get_query_execution_context,
)
SessionKey = tuple[
str,
str,
int,
str,
str,
int | str,
]
def _query_session_key(query: pipeline_query.Query) -> tuple[SessionKey, ExecutionContext]:
execution_context = get_query_execution_context(query)
bot_uuid = getattr(query, 'bot_uuid', None)
if not isinstance(bot_uuid, str) or not bot_uuid.strip():
raise ExecutionContextRequiredError('Query.bot_uuid is required for session lookup')
execution_context = bind_execution_context(execution_context, bot_uuid=bot_uuid)
key: SessionKey = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
bot_uuid,
query.launcher_type.value,
query.launcher_id,
)
return key, execution_context
class SessionManager:
"""会话管理器"""
@@ -24,17 +60,39 @@ class SessionManager:
async def get_session(self, query: pipeline_query.Query) -> provider_session.Session:
"""获取会话"""
session_key, execution_context = _query_session_key(query)
for session in self.session_list:
if query.launcher_type == session.launcher_type and query.launcher_id == session.launcher_id:
if getattr(session, '_langbot_session_key', None) == session_key:
return session
session_concurrency = self.ap.instance_config.data['concurrency']['session']
session = provider_session.Session(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
bot_uuid=query.bot_uuid,
launcher_type=query.launcher_type,
launcher_id=query.launcher_id,
sender_id=query.sender_id,
)
session_context = dataclasses.replace(
execution_context,
pipeline_uuid=None,
query_uuid=None,
)
# langbot-plugin 0.4.13 ignores Workspace fields. Preserve them until
# the Workspace-aware SDK becomes the minimum supported version.
object.__setattr__(session, 'instance_uuid', session_context.instance_uuid)
object.__setattr__(session, 'workspace_uuid', session_context.workspace_uuid)
object.__setattr__(
session,
'placement_generation',
session_context.placement_generation,
)
object.__setattr__(session, 'bot_uuid', query.bot_uuid)
object.__setattr__(session, '_execution_context', session_context)
object.__setattr__(session, '_langbot_session_key', session_key)
session._semaphore = asyncio.Semaphore(session_concurrency)
self.session_list.append(session)
return session
@@ -49,6 +107,17 @@ class SessionManager:
) -> provider_session.Conversation:
"""获取对话或创建对话"""
session_key, execution_context = _query_session_key(query)
if getattr(session, '_langbot_session_key', None) != session_key:
raise ExecutionContextMismatchError('Session does not belong to the Query execution scope')
execution_context = bind_execution_context(
execution_context,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
)
if execution_context.bot_uuid != getattr(session, 'bot_uuid', None):
raise ExecutionContextMismatchError('Session bot_uuid does not match the Query execution scope')
if not session.conversations:
session.conversations = []
@@ -63,7 +132,11 @@ class SessionManager:
messages=prompt_messages,
)
if session.using_conversation is None or session.using_conversation.pipeline_uuid != pipeline_uuid:
if (
session.using_conversation is None
or session.using_conversation.pipeline_uuid != pipeline_uuid
or session.using_conversation.bot_uuid != bot_uuid
):
conversation = provider_session.Conversation(
prompt=prompt,
messages=[],
@@ -11,7 +11,7 @@ async def is_box_backend_available(ap: Any) -> bool:
if not getattr(box_service, 'available', False):
return False
try:
status = await box_service.get_status()
status = await box_service.get_backend_status()
backend_info = status.get('backend', {})
return bool(backend_info.get('available', False))
except Exception:
+298 -98
View File
@@ -26,6 +26,9 @@ from pydantic import AnyUrl
from .. import loader
from ....core import app
from ....api.http.context import ExecutionContext
from ....api.http.service.tenant import TenantContext, require_workspace_uuid
from ....workspace.errors import WorkspaceError, WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp
@@ -223,6 +226,8 @@ class MCPToolCallTimeoutError(TimeoutError):
class RuntimeMCPSession:
"""运行时 MCP 会话"""
_FENCE_POLL_INTERVAL = 5.0
ap: app.Application
server_name: str
@@ -262,11 +267,19 @@ class RuntimeMCPSession:
_box_stdio_runtime: BoxStdioSessionRuntime
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
def __init__(
self,
server_name: str,
server_config: dict,
enable: bool,
ap: app.Application,
execution_context: ExecutionContext,
):
self.server_name = server_name
self.server_uuid = server_config.get('uuid', '')
self.server_config = server_config
self.ap = ap
self.execution_context = execution_context
self.enable = enable
self.session = None
self.tool_call_timeout_sec = self._parse_tool_call_timeout(
@@ -312,24 +325,46 @@ class RuntimeMCPSession:
self._box_stdio_runtime = BoxStdioSessionRuntime(self)
self.box_config = self._box_stdio_runtime.config
def _parse_tool_call_timeout(self, value: typing.Any) -> float:
"""Return a safe tool-call timeout; zero explicitly disables it."""
try:
timeout = -1 if isinstance(value, bool) else float(value)
if timeout > 0:
# Validate the exact conversion used for each call here, so a
# finite-but-enormous manual config cannot fail at invocation.
timedelta(seconds=timeout)
except (TypeError, ValueError, OverflowError):
timeout = -1
async def _assert_execution_active(self) -> None:
"""Fail closed when this long-lived session belongs to a stale placement."""
if not math.isfinite(timeout) or timeout < 0:
self.ap.logger.warning(
f'Invalid MCP tool call timeout {value!r} for {self.server_name}; '
f'using {MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS:g} seconds'
)
return MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
return timeout
binding = await self.ap.workspace_service.get_execution_binding(
self.execution_context.workspace_uuid,
expected_generation=self.execution_context.placement_generation,
)
if binding.instance_uuid != self.execution_context.instance_uuid:
raise WorkspaceInvariantError('MCP session instance does not match the active Workspace binding')
async def _monitor_execution_fence(self) -> None:
"""Poll the placement fence while an MCP transport is idle."""
while not self._shutdown_event.is_set():
await asyncio.sleep(self._FENCE_POLL_INTERVAL)
if self._shutdown_event.is_set():
return
await self._assert_execution_active()
async def _sleep_with_execution_fence(self, delay: float) -> None:
"""Back off without reconnecting after the captured placement expires."""
await self._assert_execution_active()
try:
await asyncio.wait_for(self._shutdown_event.wait(), timeout=delay)
except asyncio.TimeoutError:
pass
if not self._shutdown_event.is_set():
await self._assert_execution_active()
def _stop_for_stale_execution(self, error: WorkspaceError) -> None:
"""Mark the session terminal without retrying a fenced placement."""
self.status = MCPSessionStatus.ERROR
self.error_message = 'Workspace execution binding is stale'
self._shutdown_event.set()
self._ready_event.set()
self.ap.logger.info(
f'MCP session {self.server_name} stopped because its Workspace execution binding is stale: {error}'
)
async def _init_stdio_python_server(self):
if self._uses_box_stdio():
@@ -458,6 +493,7 @@ class RuntimeMCPSession:
async def _lifecycle_loop(self):
"""Manage the full MCP session lifecycle in a background task."""
try:
await self._assert_execution_active()
if self.server_config['mode'] == 'stdio':
await self._init_stdio_python_server()
elif self.server_config['mode'] == 'remote':
@@ -467,9 +503,11 @@ class RuntimeMCPSession:
elif self.server_config['mode'] == 'http':
await self._init_streamable_http_server()
else:
raise ValueError(f'Unknown MCP server mode: {self.server_name}: {self.server_config}')
raise ValueError(f'Unknown MCP server mode for {self.server_name}')
await self._assert_execution_active()
await self.refresh()
await self._assert_execution_active()
self.status = MCPSessionStatus.CONNECTED
@@ -481,12 +519,16 @@ class RuntimeMCPSession:
monitor_task = asyncio.create_task(self._box_stdio_runtime.monitor_process_health())
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
fence_task = asyncio.create_task(self._monitor_execution_fence())
done, pending = await asyncio.wait(
[shutdown_task, monitor_task, reconnect_task],
[shutdown_task, monitor_task, reconnect_task, fence_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
if fence_task in done and not self._shutdown_event.is_set():
fence_task.result()
if reconnect_task in done and not self._shutdown_event.is_set():
self._reconnect_event.clear()
self.ap.logger.info(
@@ -522,12 +564,16 @@ class RuntimeMCPSession:
else:
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
fence_task = asyncio.create_task(self._monitor_execution_fence())
done, pending = await asyncio.wait(
[shutdown_task, reconnect_task],
[shutdown_task, reconnect_task, fence_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
if fence_task in done and not self._shutdown_event.is_set():
fence_task.result()
if reconnect_task in done and not self._shutdown_event.is_set():
self._reconnect_event.clear()
self.ap.logger.info(
@@ -590,7 +636,11 @@ class RuntimeMCPSession:
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
await asyncio.sleep(1)
try:
await self._sleep_with_execution_fence(1)
except WorkspaceError as fence_error:
self._stop_for_stale_execution(fence_error)
return
continue
except _CallerReconnect:
# A tool/resource call hit a server-expired session and asked us
@@ -607,6 +657,7 @@ class RuntimeMCPSession:
self.error_message = None
self.error_phase = None
try:
await self._assert_execution_active()
if self.server_config['mode'] == 'stdio':
await self._init_stdio_python_server()
elif self.server_config['mode'] == 'remote':
@@ -616,8 +667,12 @@ class RuntimeMCPSession:
elif self.server_config['mode'] == 'http':
await self._init_streamable_http_server()
await self.refresh()
await self._assert_execution_active()
self.status = MCPSessionStatus.CONNECTED
self.ap.logger.info(f'MCP session {self.server_name} reconnected successfully after session expiry')
except WorkspaceError as reconnect_err:
self._stop_for_stale_execution(reconnect_err)
return
except Exception as reconnect_err:
self.status = MCPSessionStatus.ERROR
self.error_message = str(reconnect_err)
@@ -645,8 +700,15 @@ class RuntimeMCPSession:
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
await asyncio.sleep(2)
try:
await self._sleep_with_execution_fence(2)
except WorkspaceError as fence_error:
self._stop_for_stale_execution(fence_error)
return
continue
except WorkspaceError as e:
self._stop_for_stale_execution(e)
return
except Exception as e:
if self._shutdown_event.is_set():
return # Shutdown requested, don't retry
@@ -686,7 +748,11 @@ class RuntimeMCPSession:
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
await asyncio.sleep(delay)
try:
await self._sleep_with_execution_fence(delay)
except WorkspaceError as fence_error:
self._stop_for_stale_execution(fence_error)
return
attempt += 1
@staticmethod
@@ -769,6 +835,7 @@ class RuntimeMCPSession:
Returns True if reconnection succeeded within the timeout.
"""
await self._assert_execution_active()
if self._shutdown_event.is_set():
return False
@@ -779,6 +846,7 @@ class RuntimeMCPSession:
try:
await asyncio.wait_for(reconnected_event.wait(), timeout=self._RECONNECT_WAIT_TIMEOUT)
await self._assert_execution_active()
return self.status == MCPSessionStatus.CONNECTED
except asyncio.TimeoutError:
self.ap.logger.warning(f'MCP session {self.server_name} reconnect timed out')
@@ -794,6 +862,7 @@ class RuntimeMCPSession:
if not self.enable:
return
await self._assert_execution_active()
# Create background task for lifecycle management with retry
self._lifecycle_task = asyncio.create_task(self._lifecycle_loop_with_retry())
@@ -805,11 +874,13 @@ class RuntimeMCPSession:
self.status = MCPSessionStatus.ERROR
raise Exception(f'Connection timeout after {startup_timeout} seconds')
await self._assert_execution_active()
# Check for errors
if self.status == MCPSessionStatus.ERROR:
raise Exception('Connection failed, please check URL')
async def refresh(self):
await self._assert_execution_active()
if not self.session:
return
@@ -825,6 +896,7 @@ class RuntimeMCPSession:
self.resource_capabilities = {}
tools = await self.session.list_tools()
await self._assert_execution_active()
self.ap.logger.debug(f'Refresh MCP tools: {tools}')
@@ -846,34 +918,44 @@ class RuntimeMCPSession:
)
await self._refresh_resources()
await self._assert_execution_active()
async def _refresh_resources(self):
await self._assert_execution_active()
if not self.session:
return
try:
cursor: str | None = None
for _ in range(MCP_RESOURCE_DISCOVERY_MAX_PAGES):
await self._assert_execution_active()
resources_result = await self.session.list_resources(cursor)
await self._assert_execution_active()
for resource in resources_result.resources:
self.resources.append(_resource_to_dict(resource))
cursor = getattr(resources_result, 'nextCursor', None)
if not cursor:
break
self.ap.logger.debug(f'Refresh MCP resources: {len(self.resources)} resources found')
except WorkspaceError:
raise
except Exception as e:
self.ap.logger.debug(f'MCP server {self.server_name} does not support resources or failed to list: {e}')
try:
cursor = None
for _ in range(MCP_RESOURCE_DISCOVERY_MAX_PAGES):
await self._assert_execution_active()
templates_result = await self.session.list_resource_templates(cursor)
await self._assert_execution_active()
for template in templates_result.resourceTemplates:
self.resource_templates.append(_resource_template_to_dict(template))
cursor = getattr(templates_result, 'nextCursor', None)
if not cursor:
break
self.ap.logger.debug(f'Refresh MCP resource templates: {len(self.resource_templates)} templates found')
except WorkspaceError:
raise
except Exception as e:
self.ap.logger.debug(
f'MCP server {self.server_name} does not support resource templates or failed to list: {e}'
@@ -992,17 +1074,15 @@ class RuntimeMCPSession:
arguments: dict,
query: pipeline_query.Query | None = None,
) -> list[provider_message.ContentElement]:
await self._assert_execution_active()
for attempt in range(2):
if not self.session:
raise Exception('MCP session is not connected')
try:
read_timeout = timedelta(seconds=self.tool_call_timeout_sec) if self.tool_call_timeout_sec > 0 else None
result = await self.session.call_tool(
tool_name,
arguments,
read_timeout_seconds=read_timeout,
)
await self._assert_execution_active()
result = await self.session.call_tool(tool_name, arguments)
await self._assert_execution_active()
except Exception as e:
if self._is_tool_call_timeout(e):
self.ap.logger.warning(
@@ -1087,6 +1167,7 @@ class RuntimeMCPSession:
query: pipeline_query.Query | None = None,
) -> dict:
"""Read a resource by URI with safety limits and audit metadata."""
await self._assert_execution_active()
if not self.session:
raise Exception('MCP session is not connected')
@@ -1113,7 +1194,9 @@ class RuntimeMCPSession:
if not self.session:
raise Exception('MCP session is not connected')
try:
await self._assert_execution_active()
result = await self.session.read_resource(AnyUrl(uri))
await self._assert_execution_active()
break
except Exception as e:
if attempt == 0 and self._is_session_terminated(e):
@@ -1194,6 +1277,7 @@ class RuntimeMCPSession:
'cache_hit': False,
'warnings': warnings,
}
await self._assert_execution_active()
self._resource_cache[cache_key] = {'cached_at': now, 'envelope': envelope}
self._record_resource_read_trace(query, envelope)
return envelope
@@ -1228,7 +1312,11 @@ class RuntimeMCPSession:
def get_runtime_info_dict(self) -> dict:
info = {
'status': self.status.value,
'error_message': self.error_message,
# Raw transport exceptions may echo command arguments, headers, or
# environment values. Detailed diagnostics belong in AUDIT_VIEW
# logs; resource-list responses expose only a stable status.
'error_message': 'MCP runtime failed' if self.error_message else None,
'error_code': 'runtime_error' if self.error_message else None,
'error_phase': self.error_phase.value if self.error_phase else None,
'retry_count': self.retry_count,
'tool_count': len(self.get_tools()),
@@ -1336,6 +1424,37 @@ class RuntimeMCPSession:
await self._box_stdio_runtime.cleanup_session()
def _execution_context_from_tenant(context: TenantContext) -> ExecutionContext:
workspace_uuid = require_workspace_uuid(context)
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
generation = getattr(context, 'placement_generation', None)
if not instance_uuid:
raise ValueError('MCP runtime requires an explicit instance UUID')
if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
raise ValueError('MCP runtime requires a positive placement generation')
return ExecutionContext(
instance_uuid=instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=generation,
bot_uuid=getattr(context, 'bot_uuid', None),
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
query_uuid=getattr(context, 'query_uuid', None),
)
def _execution_context_from_query(query: pipeline_query.Query) -> ExecutionContext:
return _execution_context_from_tenant(
ExecutionContext(
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
placement_generation=getattr(query, 'placement_generation', 0) or 0,
bot_uuid=getattr(query, 'bot_uuid', None),
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
query_uuid=getattr(query, 'query_uuid', None),
)
)
# @loader.loader_class('mcp')
class MCPLoader(loader.ToolLoader):
"""MCP 工具加载器。
@@ -1343,7 +1462,7 @@ class MCPLoader(loader.ToolLoader):
在此加载器中管理所有与 MCP Server 的连接
"""
sessions: dict[str, RuntimeMCPSession]
sessions: dict[tuple[str, str, int, str], RuntimeMCPSession]
_last_listed_functions: list[resource_tool.LLMTool]
@@ -1355,6 +1474,21 @@ class MCPLoader(loader.ToolLoader):
self._last_listed_functions = []
self._hosted_mcp_tasks = []
async def _assert_execution_active(
self,
context: TenantContext,
) -> ExecutionContext:
"""Validate a caller's placement before accessing an MCP session."""
execution_context = _execution_context_from_tenant(context)
binding = await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
if binding.instance_uuid != execution_context.instance_uuid:
raise WorkspaceInvariantError('MCP caller instance does not match the active Workspace binding')
return execution_context
async def initialize(self):
await self.load_mcp_servers_from_db()
@@ -1368,15 +1502,51 @@ class MCPLoader(loader.ToolLoader):
for server in servers:
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
try:
binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid)
execution_context = ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
)
except Exception as exc:
self.ap.logger.warning(
f'Skipping MCP server {server.uuid}: Workspace execution binding is unavailable: {exc}'
)
continue
task = asyncio.create_task(self.host_mcp_server(config))
task = asyncio.create_task(self.host_mcp_server(execution_context, config))
self._hosted_mcp_tasks.append(task)
async def host_mcp_server(self, server_config: dict):
@staticmethod
def _scope_key(context: TenantContext) -> tuple[str, str, int]:
execution_context = _execution_context_from_tenant(context)
return (
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
)
@classmethod
def _session_key(cls, context: TenantContext, server_name: str) -> tuple[str, str, int, str]:
return (*cls._scope_key(context), server_name)
def _sessions_for_context(self, context: TenantContext) -> list[RuntimeMCPSession]:
scope_key = self._scope_key(context)
return [session for key, session in self.sessions.items() if key[:3] == scope_key]
async def host_mcp_server(self, context: TenantContext, server_config: dict):
execution_context = await self._assert_execution_active(context)
configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
if configured_workspace and configured_workspace != execution_context.workspace_uuid:
raise ValueError('MCP server configuration belongs to another Workspace')
server_config = dict(server_config)
server_config['workspace_uuid'] = execution_context.workspace_uuid
self.ap.logger.debug(f'Loading MCP server {server_config}')
try:
session = await self.load_mcp_server(server_config)
self.sessions[server_config['name']] = session
session = await self.load_mcp_server(execution_context, server_config)
await self._assert_execution_active(execution_context)
self.sessions[self._session_key(execution_context, server_config['name'])] = session
except Exception as e:
self.ap.logger.error(
f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
@@ -1385,6 +1555,7 @@ class MCPLoader(loader.ToolLoader):
self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})')
try:
await self._assert_execution_active(execution_context)
await session.start()
except Exception as e:
self.ap.logger.error(
@@ -1394,7 +1565,7 @@ class MCPLoader(loader.ToolLoader):
self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})')
async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession:
async def load_mcp_server(self, context: TenantContext, server_config: dict) -> RuntimeMCPSession:
"""加载 MCP 服务器到运行时
Args:
@@ -1404,6 +1575,13 @@ class MCPLoader(loader.ToolLoader):
- enable: 是否启用
- extra_args: 额外的配置参数 (可选)
"""
execution_context = await self._assert_execution_active(context)
server_config = dict(server_config)
configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
if configured_workspace and configured_workspace != execution_context.workspace_uuid:
raise ValueError('MCP server configuration belongs to another Workspace')
server_config['workspace_uuid'] = execution_context.workspace_uuid
uuid_ = server_config.get('uuid')
is_transient = False
if not uuid_:
@@ -1429,7 +1607,7 @@ class MCPLoader(loader.ToolLoader):
**extra_args,
}
session = RuntimeMCPSession(name, mixed_config, enable, self.ap)
session = RuntimeMCPSession(name, mixed_config, enable, self.ap, execution_context)
return session
@@ -1438,9 +1616,13 @@ class MCPLoader(loader.ToolLoader):
v = getattr(query, 'variables', None) or {}
return v.get('_pipeline_bound_mcp_servers', None)
def _eligible_sessions_for_bound(self, bound_mcp_servers: list[str] | None) -> list[RuntimeMCPSession]:
def _eligible_sessions_for_bound(
self,
context: TenantContext,
bound_mcp_servers: list[str] | None,
) -> list[RuntimeMCPSession]:
out: list[RuntimeMCPSession] = []
for session in self.sessions.values():
for session in self._sessions_for_context(context):
if not session.enable:
continue
if session.status != MCPSessionStatus.CONNECTED:
@@ -1452,10 +1634,14 @@ class MCPLoader(loader.ToolLoader):
out.append(session)
return out
def _eligible_resource_sessions_for_bound(self, bound_mcp_servers: list[str] | None) -> list[RuntimeMCPSession]:
def _eligible_resource_sessions_for_bound(
self,
context: TenantContext,
bound_mcp_servers: list[str] | None,
) -> list[RuntimeMCPSession]:
return [
session
for session in self._eligible_sessions_for_bound(bound_mcp_servers)
for session in self._eligible_sessions_for_bound(context, bound_mcp_servers)
if session.has_resource_support()
]
@@ -1486,12 +1672,13 @@ class MCPLoader(loader.ToolLoader):
]
async def _invoke_mcp_list_resources(self, parameters: dict, query: pipeline_query.Query) -> typing.Any:
execution_context = _execution_context_from_query(query)
server_name = parameters.get('server_name') if parameters else None
if not server_name or not isinstance(server_name, str):
return [provider_message.ContentElement.from_text('Error: "server_name" (string) is required.')]
bound = self._get_bound_mcp_from_query(query)
allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(bound)}
allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(execution_context, bound)}
if server_name not in allowed:
return [
provider_message.ContentElement.from_text(
@@ -1501,7 +1688,7 @@ class MCPLoader(loader.ToolLoader):
)
]
session = self.get_session(server_name)
session = self.get_session(execution_context, server_name)
if session is None or session.status != MCPSessionStatus.CONNECTED:
return [provider_message.ContentElement.from_text(f'Error: MCP server not connected: {server_name!r}')]
@@ -1518,6 +1705,7 @@ class MCPLoader(loader.ToolLoader):
return [provider_message.ContentElement.from_text(json.dumps(body, ensure_ascii=False, indent=2))]
async def _invoke_mcp_read_resource(self, parameters: dict, query: pipeline_query.Query) -> typing.Any:
execution_context = _execution_context_from_query(query)
server_name = parameters.get('server_name') if parameters else None
uri = parameters.get('uri') if parameters else None
if not server_name or not isinstance(server_name, str):
@@ -1526,7 +1714,7 @@ class MCPLoader(loader.ToolLoader):
return [provider_message.ContentElement.from_text('Error: "uri" (string) is required.')]
bound = self._get_bound_mcp_from_query(query)
allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(bound)}
allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(execution_context, bound)}
if server_name not in allowed:
return [
provider_message.ContentElement.from_text(
@@ -1535,7 +1723,7 @@ class MCPLoader(loader.ToolLoader):
)
]
session = self.get_session(server_name)
session = self.get_session(execution_context, server_name)
if session is None or session.status != MCPSessionStatus.CONNECTED:
return [provider_message.ContentElement.from_text(f'Error: MCP server not connected: {server_name!r}')]
@@ -1586,13 +1774,15 @@ class MCPLoader(loader.ToolLoader):
async def get_tools(
self,
context: TenantContext,
bound_mcp_servers: list[str] | None = None,
*,
include_resource_tools: bool = True,
) -> list[resource_tool.LLMTool]:
await self._assert_execution_active(context)
all_functions: list[resource_tool.LLMTool] = []
for session in self.sessions.values():
for session in self._sessions_for_context(context):
# If bound_mcp_servers is specified, only include tools from those servers
if bound_mcp_servers is not None:
if session.server_uuid in bound_mcp_servers:
@@ -1601,7 +1791,7 @@ class MCPLoader(loader.ToolLoader):
# If no bound servers specified, include all tools
all_functions.extend(session.get_tools())
if include_resource_tools and self._eligible_resource_sessions_for_bound(bound_mcp_servers):
if include_resource_tools and self._eligible_resource_sessions_for_bound(context, bound_mcp_servers):
all_functions.extend(self._mcp_synthetic_resource_tools())
self._last_listed_functions = all_functions
@@ -1610,13 +1800,15 @@ class MCPLoader(loader.ToolLoader):
async def get_tool_catalog(
self,
context: TenantContext,
bound_mcp_servers: list[str] | None = None,
*,
include_resource_tools: bool = False,
) -> list[dict[str, typing.Any]]:
await self._assert_execution_active(context)
items: list[dict[str, typing.Any]] = []
for session in self.sessions.values():
for session in self._sessions_for_context(context):
if bound_mcp_servers is not None and session.server_uuid not in bound_mcp_servers:
continue
for tool in session.get_tools():
@@ -1632,7 +1824,7 @@ class MCPLoader(loader.ToolLoader):
}
)
if include_resource_tools and self._eligible_resource_sessions_for_bound(bound_mcp_servers):
if include_resource_tools and self._eligible_resource_sessions_for_bound(context, bound_mcp_servers):
for tool in self._mcp_synthetic_resource_tools():
items.append(
{
@@ -1648,18 +1840,20 @@ class MCPLoader(loader.ToolLoader):
return items
async def has_tool(self, name: str) -> bool:
async def has_tool(self, context: TenantContext, name: str) -> bool:
"""检查工具是否存在"""
await self._assert_execution_active(context)
if name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE):
return bool(self._eligible_resource_sessions_for_bound(None))
for session in self.sessions.values():
return bool(self._eligible_resource_sessions_for_bound(context, None))
for session in self._sessions_for_context(context):
for function in session.get_tools():
if function.name == name:
return True
return False
async def get_tool(self, name: str) -> resource_tool.LLMTool | None:
for session in self.sessions.values():
async def get_tool(self, context: TenantContext, name: str) -> resource_tool.LLMTool | None:
await self._assert_execution_active(context)
for session in self._sessions_for_context(context):
for function in session.get_tools():
if function.name == name:
return function
@@ -1667,6 +1861,7 @@ class MCPLoader(loader.ToolLoader):
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
"""执行工具调用"""
execution_context = await self._assert_execution_active(_execution_context_from_query(query))
if name == MCP_TOOL_LIST_RESOURCES:
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False:
return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')]
@@ -1676,7 +1871,7 @@ class MCPLoader(loader.ToolLoader):
return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')]
return await self._invoke_mcp_read_resource(parameters, query)
for session in self.sessions.values():
for session in self._sessions_for_context(execution_context):
for function in session.get_tools():
if function.name == name:
self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}')
@@ -1690,22 +1885,25 @@ class MCPLoader(loader.ToolLoader):
raise ValueError(f'Tool not found: {name}')
async def get_resources(self, server_name: str) -> list[dict]:
async def get_resources(self, context: TenantContext, server_name: str) -> list[dict]:
"""Get resources from a specific MCP server."""
session = self.get_session(server_name)
await self._assert_execution_active(context)
session = self.get_session(context, server_name)
if session is None:
raise ValueError(f'MCP server not found: {server_name}')
return session.get_resources()
async def get_resource_templates(self, server_name: str) -> list[dict]:
async def get_resource_templates(self, context: TenantContext, server_name: str) -> list[dict]:
"""Get resource templates from a specific MCP server."""
session = self.get_session(server_name)
await self._assert_execution_active(context)
session = self.get_session(context, server_name)
if session is None:
raise ValueError(f'MCP server not found: {server_name}')
return session.get_resource_templates()
async def read_resource_envelope(
self,
context: TenantContext,
server_name: str,
uri: str,
*,
@@ -1716,7 +1914,8 @@ class MCPLoader(loader.ToolLoader):
query: pipeline_query.Query | None = None,
) -> dict:
"""Read a resource from a specific MCP server and return metadata plus contents."""
session = self.get_session(server_name)
await self._assert_execution_active(context)
session = self.get_session(context, server_name)
if session is None:
raise ValueError(f'MCP server not found: {server_name}')
return await session.read_resource_envelope(
@@ -1728,24 +1927,28 @@ class MCPLoader(loader.ToolLoader):
query=query,
)
async def read_resource(self, server_name: str, uri: str) -> list[dict]:
async def read_resource(self, context: TenantContext, server_name: str, uri: str) -> list[dict]:
"""Read a resource from a specific MCP server."""
envelope = await self.read_resource_envelope(server_name, uri)
envelope = await self.read_resource_envelope(context, server_name, uri)
return envelope['contents']
def get_session_by_uuid(self, server_uuid: str) -> RuntimeMCPSession | None:
for session in self.sessions.values():
def get_session_by_uuid(self, context: TenantContext, server_uuid: str) -> RuntimeMCPSession | None:
for session in self._sessions_for_context(context):
if session.server_uuid == server_uuid:
return session
return None
def _resolve_attachment_session(self, attachment: dict) -> RuntimeMCPSession | None:
def _resolve_attachment_session(
self,
context: TenantContext,
attachment: dict,
) -> RuntimeMCPSession | None:
server_uuid = attachment.get('server_uuid') or attachment.get('server_id')
server_name = attachment.get('server_name')
if server_uuid:
return self.get_session_by_uuid(server_uuid)
return self.get_session_by_uuid(context, server_uuid)
if server_name:
return self.get_session(server_name)
return self.get_session(context, server_name)
return None
async def build_resource_context_for_query(
@@ -1756,6 +1959,7 @@ class MCPLoader(loader.ToolLoader):
default_max_bytes: int = MCP_RESOURCE_CONTEXT_MAX_BYTES,
) -> str:
"""Build host-controlled MCP resource context for the current query."""
execution_context = await self._assert_execution_active(_execution_context_from_query(query))
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False:
return ''
@@ -1764,7 +1968,7 @@ class MCPLoader(loader.ToolLoader):
return ''
bound = self._get_bound_mcp_from_query(query)
eligible = self._eligible_resource_sessions_for_bound(bound)
eligible = self._eligible_resource_sessions_for_bound(execution_context, bound)
eligible_by_uuid = {session.server_uuid: session for session in eligible}
eligible_by_name = {session.server_name: session for session in eligible}
@@ -1772,6 +1976,7 @@ class MCPLoader(loader.ToolLoader):
remaining_tokens = default_max_tokens
for raw_attachment in attachments:
await self._assert_execution_active(execution_context)
if remaining_tokens <= 0:
break
if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled') is False:
@@ -1786,7 +1991,7 @@ class MCPLoader(loader.ToolLoader):
if not uri or not isinstance(uri, str):
continue
session = self._resolve_attachment_session(attachment)
session = self._resolve_attachment_session(execution_context, attachment)
if session is None:
continue
if session.server_uuid not in eligible_by_uuid and session.server_name not in eligible_by_name:
@@ -1804,6 +2009,8 @@ class MCPLoader(loader.ToolLoader):
source='preloaded',
query=query,
)
except WorkspaceError:
raise
except Exception as e:
self.ap.logger.warning(f'Failed to preload MCP resource {uri!r} from {session.server_name!r}: {e}')
continue
@@ -1843,37 +2050,40 @@ class MCPLoader(loader.ToolLoader):
pass
return context
async def remove_mcp_server(self, server_name: str):
async def remove_mcp_server(self, context: TenantContext, server_name: str):
"""移除 MCP 服务器"""
if server_name not in self.sessions:
await self._assert_execution_active(context)
key = self._session_key(context, server_name)
if key not in self.sessions:
self.ap.logger.warning(f'MCP server {server_name} not found in sessions, skipping removal')
return
session = self.sessions.pop(server_name)
session = self.sessions.pop(key)
await session.shutdown()
self.ap.logger.info(f'Removed MCP server: {server_name}')
def get_session(self, server_name: str) -> RuntimeMCPSession | None:
def get_session(self, context: TenantContext, server_name: str) -> RuntimeMCPSession | None:
"""获取指定名称的 MCP 会话"""
return self.sessions.get(server_name)
return self.sessions.get(self._session_key(context, server_name))
def has_session(self, server_name: str) -> bool:
def has_session(self, context: TenantContext, server_name: str) -> bool:
"""检查是否存在指定名称的 MCP 会话"""
return server_name in self.sessions
return self._session_key(context, server_name) in self.sessions
def get_all_server_names(self) -> list[str]:
def get_all_server_names(self, context: TenantContext) -> list[str]:
"""获取所有已加载的 MCP 服务器名称"""
return list(self.sessions.keys())
return [session.server_name for session in self._sessions_for_context(context)]
def get_server_tool_count(self, server_name: str) -> int:
def get_server_tool_count(self, context: TenantContext, server_name: str) -> int:
"""获取指定服务器的工具数量"""
session = self.get_session(server_name)
session = self.get_session(context, server_name)
return len(session.get_tools()) if session else 0
def get_all_servers_info(self) -> dict[str, dict]:
def get_all_servers_info(self, context: TenantContext) -> dict[str, dict]:
"""获取所有服务器的信息"""
info = {}
for server_name, session in self.sessions.items():
for session in self._sessions_for_context(context):
server_name = session.server_name
tools = session.get_tools()
info[server_name] = {
'name': server_name,
@@ -1887,23 +2097,13 @@ class MCPLoader(loader.ToolLoader):
async def shutdown(self):
"""关闭所有工具"""
self.ap.logger.info('Shutting down all MCP sessions...')
hosted_tasks = [task for task in self._hosted_mcp_tasks if not task.done()]
for task in hosted_tasks:
task.cancel()
if hosted_tasks:
await asyncio.gather(*hosted_tasks, return_exceptions=True)
self._hosted_mcp_tasks.clear()
async def shutdown_session(server_name: str, session: RuntimeMCPSession) -> None:
for key, session in list(self.sessions.items()):
try:
await session.shutdown()
self.ap.logger.debug(f'Shutdown MCP session: {server_name}')
self.ap.logger.debug(f'Shutdown MCP session: {session.server_name}')
except Exception as e:
self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}')
await asyncio.gather(
*(shutdown_session(server_name, session) for server_name, session in list(self.sessions.items()))
)
self.ap.logger.error(
f'Error shutting down MCP session {session.server_name}: {e}\n{traceback.format_exc()}'
)
self.sessions.clear()
self.ap.logger.info('All MCP sessions shutdown complete')
@@ -6,7 +6,7 @@ import os
import shutil
import shlex
import threading
from contextlib import suppress, AsyncExitStack
from contextlib import suppress, AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, Any
import pydantic
@@ -94,6 +94,60 @@ class MCPServerBoxConfig(pydantic.BaseModel):
_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0
@asynccontextmanager
async def authenticated_websocket_client(url: str, headers: dict[str, str]):
"""MCP WebSocket transport with host-only Box relay headers.
The upstream MCP helper does not expose WebSocket handshake headers. This
mirrors that transport while keeping the Box control token out of the URL,
JSON-RPC payloads, and logs.
"""
import json
import anyio
import mcp.types as mcp_types
from mcp.shared.message import SessionMessage
from pydantic import ValidationError
from websockets.asyncio.client import connect as ws_connect
from websockets.typing import Subprotocol
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
async with ws_connect(
url,
subprotocols=[Subprotocol('mcp')],
additional_headers=dict(headers),
proxy=None,
) as websocket:
async def ws_reader():
async with read_stream_writer:
async for raw_text in websocket:
try:
message = mcp_types.JSONRPCMessage.model_validate_json(raw_text)
await read_stream_writer.send(SessionMessage(message))
except ValidationError as exc: # pragma: no cover - upstream parity
await read_stream_writer.send(exc)
async def ws_writer():
async with write_stream_reader:
async for session_message in write_stream_reader:
payload = session_message.message.model_dump(
by_alias=True,
mode='json',
exclude_none=True,
)
await websocket.send(json.dumps(payload))
async with anyio.create_task_group() as task_group:
task_group.start_soon(ws_reader)
task_group.start_soon(ws_writer)
yield read_stream, write_stream
task_group.cancel_scope.cancel()
class _TransferredStack:
"""Adapts an already-populated AsyncExitStack into an async context manager
so ownership of its resources can be transferred into another exit stack.
@@ -149,6 +203,7 @@ class BoxStdioSessionRuntime:
resolved_host_path = self.resolve_host_path() if host_path is ... else host_path
return BoxWorkspaceSession(
self.ap.box_service,
self.owner.execution_context,
self.owner._build_box_session_id(),
host_path=resolved_host_path,
host_path_mode=self.config.host_path_mode,
@@ -249,7 +304,11 @@ class BoxStdioSessionRuntime:
if install_cmd:
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
payload['process_id'] = self.process_id
await workspace.box_service.start_managed_process(workspace.session_id, payload)
await workspace.box_service.start_managed_process(
workspace.execution_context,
workspace.session_id,
payload,
)
except Exception:
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
raise
@@ -259,7 +318,10 @@ class BoxStdioSessionRuntime:
f'process_id={self.process_id} (transport reconnect)'
)
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
(
websocket_url,
websocket_headers,
) = await workspace.get_managed_process_websocket_connection(self.process_id)
# Attach the WS transport + MCP session ONCE, on the owner's exit stack,
# in the same task as the serve loop that follows. websocket_client and
@@ -277,7 +339,12 @@ class BoxStdioSessionRuntime:
# attempt re-attaches to the same live process; once it has finished
# cold start the handshake succeeds and stays healthy.
try:
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
transport_context = (
authenticated_websocket_client(websocket_url, websocket_headers)
if websocket_headers
else websocket_client(websocket_url)
)
transport = await self.owner.exit_stack.enter_async_context(transport_context)
read_stream, write_stream = transport
self.owner.session = await self.owner.exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
@@ -11,6 +11,7 @@ from .. import loader
from ..errors import ToolNotFoundError
from .availability import is_box_backend_available
from . import skill as skill_loader
from ....api.http.context import ExecutionContext
EXEC_TOOL_NAME = 'exec'
READ_TOOL_NAME = 'read'
@@ -56,6 +57,17 @@ class NativeToolLoader(loader.ToolLoader):
"""Check if the box backend is truly available (not just the runtime)."""
return await is_box_backend_available(self.ap)
@staticmethod
def _execution_context(query: pipeline_query.Query) -> ExecutionContext:
return ExecutionContext(
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
placement_generation=getattr(query, 'placement_generation', 0) or 0,
bot_uuid=getattr(query, 'bot_uuid', None),
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
query_uuid=getattr(query, 'query_uuid', None),
)
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
if not await self._is_sandbox_available():
return []
@@ -142,7 +154,7 @@ class NativeToolLoader(loader.ToolLoader):
result = self._normalize_exec_result(result)
if selected_skill is not None:
self._refresh_skill_from_disk(selected_skill)
self._refresh_skill_from_disk(query, selected_skill)
return result
def _resolve_host_path(
@@ -162,7 +174,11 @@ class NativeToolLoader(loader.ToolLoader):
)
box_service = self.ap.box_service
host_root = selected_skill.get('package_root') if selected_skill is not None else box_service.default_workspace
host_root = (
selected_skill.get('package_root')
if selected_skill is not None
else box_service._tenant_workspace(self._execution_context(query))
)
if not host_root:
raise ValueError('No host workspace configured for file operations.')
@@ -522,11 +538,19 @@ else:
return self._read_text_file_preview(host_path, parameters)
try:
result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative)
result = await self.ap.box_service.read_skill_file(
self._execution_context(query),
selected_skill['name'],
relative,
)
return self._build_read_result_from_text(str(result.get('content', '')), parameters)
except Exception:
try:
result = await self.ap.box_service.list_skill_files(selected_skill['name'], relative)
result = await self.ap.box_service.list_skill_files(
self._execution_context(query),
selected_skill['name'],
relative,
)
entries = [entry['name'] for entry in result.get('entries', [])]
return self._build_directory_result(entries)
except Exception as exc:
@@ -562,8 +586,9 @@ else:
if encoding != 'text':
return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'}
selected_skill, relative = skill_request
await self.ap.box_service.write_skill_file(selected_skill['name'], relative, content)
await self.ap.skill_mgr.reload_skills()
execution_context = self._execution_context(query)
await self.ap.box_service.write_skill_file(execution_context, selected_skill['name'], relative, content)
await self.ap.skill_mgr.reload_skills(execution_context)
return {'ok': True, 'path': path}
host_path, selected_skill = self._resolve_host_path(
@@ -579,7 +604,7 @@ else:
self._write_host_file(host_path, content, parameters)
except ValueError as exc:
return {'ok': False, 'error': str(exc)}
self._refresh_skill_from_disk(selected_skill)
self._refresh_skill_from_disk(query, selected_skill)
return {'ok': True, 'path': path}
async def _invoke_edit(self, parameters: dict, query: pipeline_query.Query) -> dict:
@@ -603,7 +628,11 @@ else:
):
selected_skill, relative = skill_request
try:
result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative)
result = await self.ap.box_service.read_skill_file(
self._execution_context(query),
selected_skill['name'],
relative,
)
except Exception:
return {'ok': False, 'error': f'File not found: {path}'}
content = result.get('content', '')
@@ -613,8 +642,14 @@ else:
if count > 1:
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
new_content = content.replace(old_string, new_string, 1)
await self.ap.box_service.write_skill_file(selected_skill['name'], relative, new_content)
await self.ap.skill_mgr.reload_skills()
execution_context = self._execution_context(query)
await self.ap.box_service.write_skill_file(
execution_context,
selected_skill['name'],
relative,
new_content,
)
await self.ap.skill_mgr.reload_skills(execution_context)
return {'ok': True, 'path': path}
host_path, selected_skill = self._resolve_host_path(
@@ -637,10 +672,10 @@ else:
new_content = content.replace(old_string, new_string, 1)
with open(host_path, 'w', encoding='utf-8') as f:
f.write(new_content)
self._refresh_skill_from_disk(selected_skill)
self._refresh_skill_from_disk(query, selected_skill)
return {'ok': True, 'path': path}
def _refresh_skill_from_disk(self, selected_skill: dict | None) -> None:
def _refresh_skill_from_disk(self, query: pipeline_query.Query, selected_skill: dict | None) -> None:
if selected_skill is None:
return
@@ -650,7 +685,7 @@ else:
refresh_skill = getattr(skill_mgr, 'refresh_skill_from_disk', None)
if callable(refresh_skill):
refresh_skill(selected_skill.get('name', ''))
refresh_skill(self._execution_context(query), selected_skill.get('name', ''))
async def _is_sandbox_available(self) -> bool:
"""Refresh backend availability so Box reconnects restore tool exposure."""
@@ -67,7 +67,11 @@ class PluginToolLoader(loader.ToolLoader):
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
try:
return await self.ap.plugin_connector.call_tool(
name, parameters, session=query.session, query_id=query.query_id
name,
parameters,
session=query.session,
query_id=query.query_id,
query_uuid=query.query_uuid,
)
except Exception as e:
self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')
@@ -4,6 +4,7 @@ import re
import typing
from ....box import workspace as box_workspace
from ....api.http.context import ExecutionContext
if typing.TYPE_CHECKING:
from ....core import app
@@ -36,7 +37,15 @@ def get_visible_skills(ap: app.Application, query: pipeline_query.Query) -> dict
if skill_mgr is None:
return {}
visible_skills = getattr(skill_mgr, 'skills', {})
execution_context = ExecutionContext(
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
placement_generation=getattr(query, 'placement_generation', 0) or 0,
bot_uuid=getattr(query, 'bot_uuid', None),
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
query_uuid=getattr(query, 'query_uuid', None),
)
visible_skills = skill_mgr.get_skills(execution_context)
bound_skills = get_bound_skill_names(query)
if bound_skills is None:
return visible_skills

Some files were not shown because too many files have changed in this diff Show More