diff --git a/docs/pipeline-migration-config-map.zh-CN.md b/docs/pipeline-migration-config-map.zh-CN.md index 6f3b4bbe5..d3c90077c 100644 --- a/docs/pipeline-migration-config-map.zh-CN.md +++ b/docs/pipeline-migration-config-map.zh-CN.md @@ -46,9 +46,9 @@ ### LocalAgent(28 项) -- **`ai.local-agent.box-session-id-template`** → `Host execution_context.build_host_box_scope + Box service`。 - - 缺失、空值或原生默认 {launcher_type}_{launcher_id} 不写入新 Runner;用户确认明确的 Box 状态重置警告后创建新隔离会话。任何其他自定义共享模板仍阻断,不自动复制文件或跨作用域授权。 - - 所有权:`host_tenancy_and_existing_file_state`;原生依据:`src/langbot/pkg/box/service.py:641`。 +- **`ai.local-agent.box-session-id-template`** → `R.box-session-id-template`。 + - 全局、每会话、每用户、每对话上下文、每消息及有效自定义模板均原样保留;缺失时沿用插件默认 `{launcher_type}_{launcher_id}`。插件清单的 `allow_custom: true` 允许非预制值。空值、错误括号、位置参数、格式转换和属性访问会提示修正;公开请求变量保留,但运行时必须有值。复用范围保留不等于迁移旧容器及文件,新运行器按同样规则申请新的沙箱。 + - 所有权:Runner 管理复用策略、申请、绑定及附件传输;Host 管理连接、工作区隔离和配额。沙箱开关沿用 LocalAgent 插件默认 `true`,仅在有沙箱工具授权且平台支持时使用。 - **`ai.local-agent.enable-all-tools`** → `R.enable-all-tools`。 - 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。 - 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:34`。 diff --git a/pyproject.toml b/pyproject.toml index 096787729..c93df3414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -235,4 +235,4 @@ skip-magic-trailing-comma = false line-ending = "auto" [tool.uv.sources] -langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "94c098ee0d1c4535043d9bf4a74c25f90849bc41" } +langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9" } diff --git a/src/langbot/pkg/api/http/service/pipeline_migration.py b/src/langbot/pkg/api/http/service/pipeline_migration.py index 354725a16..734107f84 100644 --- a/src/langbot/pkg/api/http/service/pipeline_migration.py +++ b/src/langbot/pkg/api/http/service/pipeline_migration.py @@ -11,12 +11,15 @@ import copy import hashlib import hmac import json +import math +import time import secrets import sys import uuid from langbot_plugin.runtime.plugin.mgr import PluginInstallSource +import httpx import sqlalchemy as sa from ..authz import Permission, permissions_for_role, require_permission @@ -37,9 +40,78 @@ from ....entity.persistence.pipeline_migration import PipelineMigrationSnapshot from ....entity.persistence.plugin import PluginSetting from ....entity.persistence.user import User from ....entity.persistence.workspace import Workspace, WorkspaceMembership, WorkspaceExecutionState +from ....plugin.errors import ( + PluginRuntimeNotConnectedError, + MarketplacePluginVersionNotFoundError, + PluginInstallationFailedError, +) from ....pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline +class MigrationInstallContext(TaskContext): + """Publish only structured installer progress, never upstream logs or URLs.""" + + ACTIONS = { + 'downloading plugin package': 'downloading', + 'validating plugin package': 'validating', + 'preparing plugin installation': 'preparing', + 'installing plugin dependencies': 'installing_deps', + 'waiting for plugin initialization': 'activating', + 'refreshing plugin components': 'refreshing', + 'plugin installed': 'done', + } + METRICS = ('progress_percent', 'download_current', 'download_total', 'download_speed', 'deps_total') + + def __init__(self, target): + super().__init__() + self.progress = { + **target, + 'status': 'installing', + 'stage': 'checking', + 'code': None, + 'started_at': time.time(), + 'finished_at': None, + 'steps': [], + } + self._step('checking') + + def _step(self, stage): + steps = self.progress['steps'] + if steps and steps[-1]['stage'] == stage: + return + now = time.time() + if steps: + steps[-1]['finished_at'] = now + steps.append({'stage': stage, 'started_at': now, 'finished_at': None}) + self.progress['stage'] = stage + + def set_current_action(self, action): + super().set_current_action(action) + stage = self.ACTIONS.get(action) + if stage: + self._step(stage) + self.publish() + + def publish(self): + for key in self.METRICS: + value = self.metadata.get(key) + if type(value) in (int, float) and math.isfinite(value) and value >= 0: + self.progress[key] = min(value, 100) if key == 'progress_percent' else value + self.progress['updated_at'] = time.time() + + def finish(self, code=None): + self.publish() + self.progress.update(status='failed' if code else 'completed', code=code, finished_at=time.time()) + self.progress['steps'][-1]['finished_at'] = self.progress['finished_at'] + if not code: + self.progress['progress_percent'] = 100 + + async def observe(self): + while True: + self.publish() + await asyncio.sleep(0.2) + + class MigrationError(ValueError): """Only constant, credential-free codes cross the API/task boundary.""" @@ -49,6 +121,14 @@ class MigrationError(ValueError): self.status_code = status_code +def _failure_code(exc: Exception) -> str: + if isinstance(exc, MigrationError): + return exc.code + if isinstance(exc, PluginRuntimeNotConnectedError): + return 'plugin_runtime_unavailable' + return 'migration_failed' + + def validate_execute_request(body) -> list[dict]: if not isinstance(body, dict) or set(body) != {'confirmed', 'items'} or body['confirmed'] is not True: raise MigrationError('confirmation_required', 400) @@ -368,6 +448,7 @@ class PipelineMigrationService: task_context.metadata = { 'kind': 'pipeline_migration', 'phase': 'installing' if install_plugins else 'migrating', + 'installations': [], 'results': [{'pipeline_uuid': row['uuid'], 'state': 'pending', 'code': None} for row in sources], } task = self.ap.task_mgr.create_user_task( @@ -429,12 +510,13 @@ class PipelineMigrationService: ) if not ready: task_context.metadata['phase'] = 'installing' + install_context = MigrationInstallContext(target) + task_context.metadata['installations'].append(install_context.progress) + observer = asyncio.create_task(install_context.observe()) + install_code = 'operation_cancelled' try: await self._authorize(ctx) await self.ap.plugin_connector.require_workspace_context(execution) - # Keep installer diagnostics out of the user task: upstream - # errors may contain credentials. Reuse normal quota and - # runtime-readiness checks in the connector. await self.ap.plugin_connector.install_plugin( PluginInstallSource.MARKETPLACE, { @@ -442,11 +524,38 @@ class PipelineMigrationService: 'plugin_name': target['name'], 'plugin_version': target['version'], }, - task_context=TaskContext.new(), + task_context=install_context, ) - installed[key] = None + install_code = None + except PluginRuntimeNotConnectedError: + install_code = 'plugin_runtime_unavailable' + except MarketplacePluginVersionNotFoundError: + install_code = 'plugin_version_unavailable' + except httpx.TimeoutException: + install_code = 'plugin_download_timeout' + except httpx.HTTPStatusError as exc: + install_code = ( + 'plugin_version_unavailable' + if exc.response.status_code == 404 + else 'plugin_marketplace_unavailable' + ) + except httpx.RequestError: + install_code = 'plugin_download_failed' + except PluginInstallationFailedError as exc: + install_code = { + 'dependency_prepare_failed': 'dependency_prepare_failed', + 'worker_launch_failed': 'plugin_launch_failed', + }.get(exc.error_code, 'plugin_install_failed') except Exception: - installed[key] = 'plugin_install_failed' + install_code = 'plugin_install_failed' + finally: + observer.cancel() + try: + await observer + except asyncio.CancelledError: + pass + install_context.finish(install_code) + installed[key] = install_code else: installed[key] = None if installed[key]: @@ -467,7 +576,7 @@ class PipelineMigrationService: except Exception as exc: result.update( state='blocked' if isinstance(exc, MigrationError) else 'failed', - code=exc.code if isinstance(exc, MigrationError) else 'migration_failed', + code=_failure_code(exc), ) if any( r['code'] in ('operation_cancelled', 'commit_outcome_unknown') @@ -557,7 +666,7 @@ class PipelineMigrationService: ) if not valid: raise MigrationError('runner_schema_incompatible') - if kind == 'select' and field.get('options'): + if kind == 'select' and field.get('options') and field.get('allow_custom') is not True: if value not in [o.get('value', o.get('name')) for o in field['options']]: raise MigrationError('runner_schema_incompatible') if any( @@ -771,13 +880,7 @@ class PipelineMigrationService: except (Exception, asyncio.CancelledError) as exc: cancelled = isinstance(exc, asyncio.CancelledError) reconciliation_cancel = None - code = ( - 'operation_cancelled' - if cancelled - else exc.code - if isinstance(exc, MigrationError) - else 'migration_failed' - ) + code = 'operation_cancelled' if cancelled else _failure_code(exc) if commit_attempted and not committed and not isinstance(exc, MigrationError): # A commit can succeed while acknowledgement/connection # cleanup fails, including cancellation. Make one read diff --git a/src/langbot/pkg/pipeline/legacy_config_migration.py b/src/langbot/pkg/pipeline/legacy_config_migration.py index 928c41aeb..d893eb0e1 100644 --- a/src/langbot/pkg/pipeline/legacy_config_migration.py +++ b/src/langbot/pkg/pipeline/legacy_config_migration.py @@ -17,6 +17,7 @@ import json import math import re from urllib.parse import urlsplit +from string import Formatter PLANNER_VERSION = '3' @@ -344,7 +345,18 @@ def _validate_local(result, section): _warn(result, 'local.retrieval_defaults', f'{prefix}.knowledge-bases') template = section.get('box-session-id-template', '{launcher_type}_{launcher_id}') if type(template) is not str or not template.strip(): - _block(result, 'invalid_type', f'{prefix}.box-session-id-template') + _block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template') + else: + try: + fields = list(Formatter().parse(template)) + if any( + name is not None + and (not name or name.isdecimal() or re.search(r'[.\[\]{}]', name) or spec or conversion) + for _, name, spec, conversion in fields + ): + raise ValueError('Only named interpolation variables are supported') + except ValueError: + _block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template') _warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template') diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index f43bedfa8..ee8ce2dd5 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -23,6 +23,11 @@ from langbot_plugin.api.entities.builtin.pipeline.query import provider_session from ..core import app from . import handler +from .errors import ( + PluginRuntimeNotConnectedError, + PluginInstallationFailedError, + MarketplacePluginVersionNotFoundError, +) from .archive import inspect_plugin_archive_metadata from .github import ( validate_github_plugin_install_info, @@ -89,8 +94,10 @@ async def _read_httpx_response_limited( response: httpx.Response, *, max_bytes: int, + task_context: taskmgr.TaskContext | None = None, ) -> bytes: content_length = response.headers.get('content-length') + declared_size = None if content_length is not None: try: declared_size = int(content_length) @@ -99,9 +106,16 @@ async def _read_httpx_response_limited( if declared_size is not None and declared_size > max_bytes: raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit') + started = time.monotonic() + if task_context is not None: + task_context.metadata.update(download_current=0, download_total=max(declared_size or 0, 0)) body = bytearray() async for chunk in response.aiter_bytes(chunk_size=64 * 1024): body.extend(chunk) + if task_context is not None: + task_context.metadata.update( + download_current=len(body), download_speed=len(body) / max(time.monotonic() - started, 0.001) + ) if len(body) > max_bytes: raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit') return bytes(body) @@ -113,14 +127,25 @@ async def _marketplace_get( *, max_bytes: int, allow_not_found: bool = False, + task_context: taskmgr.TaskContext | None = None, ) -> tuple[int, bytes]: async with client.stream('GET', url) as response: if allow_not_found and response.status_code == 404: return response.status_code, b'' + if response.is_error: + body = await _read_httpx_response_limited(response, max_bytes=min(max_bytes, 64 * 1024)) + try: + payload = json.loads(body) + except (ValueError, UnicodeDecodeError): + payload = {} + # Space currently returns HTTP 500 for a missing plugin release. + if isinstance(payload, dict) and str(payload.get('msg', '')).startswith('plugin version not found:'): + raise MarketplacePluginVersionNotFoundError('The requested plugin version is not available') response.raise_for_status() return response.status_code, await _read_httpx_response_limited( response, max_bytes=max_bytes, + task_context=task_context, ) @@ -134,25 +159,6 @@ def _decode_json_object(body: bytes, *, subject: str) -> dict[str, Any]: return payload -class PluginRuntimeNotConnectedError(RuntimeError): - """Raised when plugin runtime operations are requested before connection.""" - - -class PluginInstallationFailedError(RuntimeError): - """Stable Runtime desired-state failure for one plugin installation.""" - - def __init__( - self, - installation_uuid: str, - error_code: str, - message: str, - ) -> None: - self.installation_uuid = installation_uuid - self.error_code = error_code - self.runtime_message = message - super().__init__(f'Plugin installation {installation_uuid} failed [{error_code}]: {message}') - - class PluginRuntimeConnector(ManagedRuntimeConnector): """Plugin runtime connector""" @@ -1654,6 +1660,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): client, f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{version}', max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES, + task_context=task_context, ) return package, version diff --git a/src/langbot/pkg/plugin/errors.py b/src/langbot/pkg/plugin/errors.py new file mode 100644 index 000000000..00bd5c995 --- /dev/null +++ b/src/langbot/pkg/plugin/errors.py @@ -0,0 +1,24 @@ +"""Shared plugin operation errors without runtime or application imports.""" + + +class PluginRuntimeNotConnectedError(RuntimeError): + """Raised when plugin runtime operations are requested before connection.""" + + +class PluginInstallationFailedError(RuntimeError): + """Stable Runtime desired-state failure for one plugin installation.""" + + def __init__( + self, + installation_uuid: str, + error_code: str, + message: str, + ) -> None: + self.installation_uuid = installation_uuid + self.error_code = error_code + self.runtime_message = message + super().__init__(f'Plugin installation {installation_uuid} failed [{error_code}]: {message}') + + +class MarketplacePluginVersionNotFoundError(ValueError): + """The requested release is not available from the marketplace.""" diff --git a/tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json b/tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json index c3b98c5ae..c0c7aeb51 100644 --- a/tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json +++ b/tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json @@ -7,11 +7,11 @@ "name": "model", "description": { "en_US": "Primary/fallback model UUIDs and per-model reasoning levels. Host validates and applies reasoning for every request; this is per-pipeline configuration.", - "zh_Hans": "\u4e3b\u6a21\u578b/\u5907\u7528\u6a21\u578b UUID \u4e0e\u5404\u6a21\u578b\u63a8\u7406\u7b49\u7ea7\u3002\u7531 Host \u5bf9\u6bcf\u6b21\u8bf7\u6c42\u6821\u9a8c\u5e76\u5e94\u7528\uff0c\u5c5e\u4e8e\u6d41\u6c34\u7ebf\u72ec\u7acb\u914d\u7f6e\u3002" + "zh_Hans": "主模型/备用模型 UUID 与各模型推理等级。由 Host 对每次请求校验并应用,属于流水线独立配置。" }, "label": { "en_US": "Model", - "zh_Hans": "\u6a21\u578b" + "zh_Hans": "模型" }, "type": "model-fallback-selector", "required": true, @@ -25,7 +25,7 @@ "name": "prompt", "label": { "en_US": "Prompt", - "zh_Hans": "\u63d0\u793a\u8bcd" + "zh_Hans": "提示词" }, "type": "prompt-editor", "required": true, @@ -40,21 +40,122 @@ "name": "knowledge-bases", "label": { "en_US": "Knowledge Bases", - "zh_Hans": "\u77e5\u8bc6\u5e93" + "zh_Hans": "知识库" }, "type": "knowledge-base-multi-selector", "required": false, "default": [] }, + { + "name": "box-enabled", + "label": { + "en_US": "Sandbox", + "zh_Hans": "沙箱" + }, + "description": { + "en_US": "Use Box for code execution and file processing.", + "zh_Hans": "使用 Box 执行代码和处理文件。" + }, + "type": "boolean", + "required": false, + "default": true + }, + { + "name": "box-session-id-template", + "label": { + "en_US": "Sandbox reuse", + "zh_Hans": "沙箱复用范围" + }, + "description": { + "en_US": "Choose how sandbox files and environments are shared. Custom templates substitute {variable_name}, for example {launcher_type}_{launcher_id}_{sender_id}. Equal results reuse the same sandbox; {global} shares one sandbox throughout the workspace. Available variables: {launcher_type}, {launcher_id}, {sender_id}, {conversation_id}, {bot_id}, {query_id}, {run_id}, {event_id}, {event_type}, and public request variables. Only named variables are supported, not expressions or attribute access; unavailable variables cause an error.", + "zh_Hans": "选择沙箱文件和环境的共享方式。自定义模板使用 {变量名} 插值,如 {launcher_type}_{launcher_id}_{sender_id};插值结果相同就复用同一个沙箱,{global} 表示在工作区内全局共享。可用变量:{launcher_type}(会话类型)、{launcher_id}(会话 ID)、{sender_id}(用户 ID)、{conversation_id}(对话上下文 ID)、{bot_id}(机器人 ID)、{query_id}(请求 ID)、{run_id}(运行 ID)、{event_id}(事件 ID)、{event_type}(事件类型),也可引用公开请求变量。仅支持变量名,不支持运算或属性访问;变量不可用时会报错。" + }, + "type": "select", + "allow_custom": true, + "required": true, + "default": "{launcher_type}_{launcher_id}", + "options": [ + { + "name": "{global}", + "label": { + "en_US": "Global (shared by all)", + "zh_Hans": "全局(所有人共享)", + "zh_Hant": "全域(所有人共用)", + "ja_JP": "グローバル(全員共有)", + "vi_VN": "Toàn cục (chia sẻ cho tất cả)", + "th_TH": "ทั่วไป (แชร์ทั้งหมด)", + "es_ES": "Global (compartido por todos)", + "ru_RU": "Глобальный (общий для всех)" + } + }, + { + "name": "{launcher_type}_{launcher_id}", + "label": { + "en_US": "Per chat (Recommended)", + "zh_Hans": "每个会话(推荐)", + "zh_Hant": "每個會話(推薦)", + "ja_JP": "チャットごと(推奨)", + "vi_VN": "Mỗi cuộc trò chuyện (Khuyến nghị)", + "th_TH": "ต่อแชท (แนะนำ)", + "es_ES": "Por chat (Recomendado)", + "ru_RU": "По чату (Рекомендуется)" + } + }, + { + "name": "{launcher_type}_{launcher_id}_{sender_id}", + "label": { + "en_US": "Per user in chat", + "zh_Hans": "会话中每个用户", + "zh_Hant": "會話中每個用戶", + "ja_JP": "チャット内のユーザーごと", + "vi_VN": "Mỗi người dùng trong cuộc trò chuyện", + "th_TH": "ต่อผู้ใช้ในแชท", + "es_ES": "Por usuario en chat", + "ru_RU": "По пользователю в чате" + } + }, + { + "name": "{launcher_type}_{launcher_id}_{conversation_id}", + "label": { + "en_US": "Per conversation context", + "zh_Hans": "每个对话上下文", + "zh_Hant": "每個對話上下文", + "ja_JP": "会話コンテキストごと", + "vi_VN": "Mỗi ngữ cảnh hội thoại", + "th_TH": "ต่อบริบทการสนทนา", + "es_ES": "Por contexto de conversación", + "ru_RU": "По контексту разговора" + } + }, + { + "name": "{query_id}", + "label": { + "en_US": "Per message (isolated)", + "zh_Hans": "每条消息(完全隔离)", + "zh_Hant": "每條訊息(完全隔離)", + "ja_JP": "メッセージごと(隔離)", + "vi_VN": "Mỗi tin nhắn (cách ly)", + "th_TH": "ต่อข้อความ (แยกส่วน)", + "es_ES": "Por mensaje (aislado)", + "ru_RU": "По сообщению (изолированно)" + } + } + ], + "show_if": { + "field": "box-enabled", + "operator": "eq", + "value": true + } + }, { "name": "advanced-settings", "label": { "en_US": "Advanced Settings", - "zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e" + "zh_Hans": "高级设置" }, "description": { "en_US": "Show tuning controls for retrieval, tools, timeouts, and context management.", - "zh_Hans": "\u663e\u793a\u68c0\u7d22\u3001\u5de5\u5177\u3001\u8d85\u65f6\u548c\u4e0a\u4e0b\u6587\u7ba1\u7406\u7684\u8c03\u4f18\u9009\u9879\u3002" + "zh_Hans": "显示检索、工具、超时和上下文管理的调优选项。" }, "type": "boolean", "required": false, @@ -64,11 +165,11 @@ "name": "date-grounding", "label": { "en_US": "Current Date Grounding", - "zh_Hans": "\u5f53\u524d\u65e5\u671f\u951a\u5b9a" + "zh_Hans": "当前日期锚定" }, "description": { "en_US": "Add the current UTC date and a reminder to verify time-sensitive facts with available search tools.", - "zh_Hans": "\u6ce8\u5165\u5f53\u524d UTC \u65e5\u671f\uff0c\u5e76\u63d0\u9192\u4f7f\u7528\u53ef\u7528\u641c\u7d22\u5de5\u5177\u6838\u5b9e\u6709\u65f6\u6548\u6027\u7684\u4fe1\u606f\u3002" + "zh_Hans": "注入当前 UTC 日期,并提醒使用可用搜索工具核实有时效性的信息。" }, "type": "boolean", "required": false, @@ -83,7 +184,7 @@ "name": "timeout", "label": { "en_US": "Timeout", - "zh_Hans": "\u6267\u884c\u8d85\u65f6" + "zh_Hans": "执行超时" }, "type": "integer", "required": false, @@ -98,7 +199,7 @@ "name": "remove-think", "label": { "en_US": "Remove Thinking Output", - "zh_Hans": "\u79fb\u9664\u601d\u8003\u5185\u5bb9" + "zh_Hans": "移除思考内容" }, "type": "boolean", "required": false, @@ -113,7 +214,7 @@ "name": "retrieval-top-k", "label": { "en_US": "Retrieval Top K", - "zh_Hans": "\u77e5\u8bc6\u5e93\u68c0\u7d22\u6570\u91cf" + "zh_Hans": "知识库检索数量" }, "type": "integer", "required": false, @@ -128,7 +229,7 @@ "name": "rerank-model", "label": { "en_US": "Rerank Model", - "zh_Hans": "\u91cd\u6392\u5e8f\u6a21\u578b" + "zh_Hans": "重排序模型" }, "type": "rerank-model-selector", "required": false, @@ -143,7 +244,7 @@ "name": "rerank-top-k", "label": { "en_US": "Rerank Top K", - "zh_Hans": "\u91cd\u6392\u5e8f\u4fdd\u7559\u6570\u91cf" + "zh_Hans": "重排序保留数量" }, "type": "integer", "required": false, @@ -158,7 +259,7 @@ "name": "max-tool-iterations", "label": { "en_US": "Max Tool Iterations", - "zh_Hans": "\u6700\u5927\u5de5\u5177\u8c03\u7528\u8f6e\u6570" + "zh_Hans": "最大工具调用轮数" }, "type": "integer", "required": false, @@ -173,11 +274,11 @@ "name": "tool-execution-mode", "description": { "en_US": "Parallel is faster for independent tools. Use serial for dependent or side-effecting actions; result order does not guarantee execution order.", - "zh_Hans": "\u5e76\u884c\u9002\u5408\u72ec\u7acb\u5de5\u5177\uff1b\u6709\u4f9d\u8d56\u6216\u526f\u4f5c\u7528\u7684\u64cd\u4f5c\u8bf7\u9009\u62e9\u4e32\u884c\u3002\u7ed3\u679c\u6392\u5217\u4e0d\u4fdd\u8bc1\u6267\u884c\u987a\u5e8f\u3002" + "zh_Hans": "并行适合独立工具;有依赖或副作用的操作请选择串行。结果排列不保证执行顺序。" }, "label": { "en_US": "Tool Execution Mode", - "zh_Hans": "\u5de5\u5177\u6267\u884c\u6a21\u5f0f" + "zh_Hans": "工具执行模式" }, "type": "select", "required": false, @@ -187,14 +288,14 @@ "name": "parallel", "label": { "en_US": "Parallel", - "zh_Hans": "\u5e76\u884c" + "zh_Hans": "并行" } }, { "name": "serial", "label": { "en_US": "Serial", - "zh_Hans": "\u4e32\u884c" + "zh_Hans": "串行" } } ], @@ -208,7 +309,7 @@ "name": "max-tool-result-chars", "label": { "en_US": "Max Tool Result Characters", - "zh_Hans": "\u6700\u5927\u5de5\u5177\u7ed3\u679c\u5b57\u7b26\u6570" + "zh_Hans": "最大工具结果字符数" }, "type": "integer", "required": false, @@ -223,7 +324,7 @@ "name": "context-history-fetch-limit", "label": { "en_US": "History Fetch Limit", - "zh_Hans": "\u5386\u53f2\u6d88\u606f\u62c9\u53d6\u6570\u91cf" + "zh_Hans": "历史消息拉取数量" }, "type": "integer", "required": false, @@ -238,7 +339,7 @@ "name": "context-window-tokens", "label": { "en_US": "Context Window Tokens", - "zh_Hans": "\u4e0a\u4e0b\u6587\u7a97\u53e3 Token \u6570" + "zh_Hans": "上下文窗口 Token 数" }, "type": "integer", "required": false, @@ -253,7 +354,7 @@ "name": "context-reserve-tokens", "label": { "en_US": "Reserved Output Tokens", - "zh_Hans": "\u8f93\u51fa\u4fdd\u7559 Token \u6570" + "zh_Hans": "输出保留 Token 数" }, "type": "integer", "required": false, @@ -268,7 +369,7 @@ "name": "context-keep-recent-tokens", "label": { "en_US": "Recent Context Tokens", - "zh_Hans": "\u6700\u8fd1\u4e0a\u4e0b\u6587 Token \u6570" + "zh_Hans": "最近上下文 Token 数" }, "type": "integer", "required": false, @@ -283,7 +384,7 @@ "name": "context-summary-tokens", "label": { "en_US": "Summary Tokens", - "zh_Hans": "\u6458\u8981 Token \u6570" + "zh_Hans": "摘要 Token 数" }, "type": "integer", "required": false, diff --git a/tests/unit_tests/api/service/test_pipeline_migration.py b/tests/unit_tests/api/service/test_pipeline_migration.py index ba41e092d..901dfc608 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration.py +++ b/tests/unit_tests/api/service/test_pipeline_migration.py @@ -864,3 +864,90 @@ async def test_all_mode_rejects_duplicate_tasks_and_unconfirmed_requests(env): with pytest.raises(env.m.MigrationError, match='migration_running'): await execute_all(env) env.svc._all_tasks.clear() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('stage', ['discovery', 'install', 'validation']) +async def test_disconnected_runtime_has_specific_error_without_modifying_sources(env, stage): + from langbot.pkg.plugin.connector import PluginRuntimeNotConnectedError + + error = PluginRuntimeNotConnectedError('private connection diagnostics') + if stage == 'discovery': + env.ap.runner_registry.list_runners.side_effect = error + elif stage == 'install': + async with env.engine.begin() as conn: + await conn.execute(sa.delete(PluginSetting)) + env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=error) + else: + env.svc._verify_runtime = AsyncMock(side_effect=error) + _, metadata = await execute_all(env) + assert all(r['code'] == 'plugin_runtime_unavailable' for r in metadata['results']) + assert 'private connection diagnostics' not in str(metadata) + configs, backups = await rows(env) + assert configs['one'] == SOURCE and configs['two'] == SOURCE + assert not backups + + +def test_install_progress_records_stages_without_exposing_diagnostics(): + from langbot.pkg.api.http.service.pipeline_migration import MigrationInstallContext + + ctx = MigrationInstallContext({'author': 'team', 'name': 'Runner', 'version': '1.0'}) + ctx.trace('private-token', action='downloading plugin package') + ctx.metadata.update(download_current=120, download_total=240, progress_percent=15, url='private-token') + ctx.set_current_action('validating plugin package') + ctx.metadata['progress_percent'] = float('nan') + ctx.finish('plugin_install_failed') + assert ctx.progress['download_current'] == 120 + assert ctx.progress['progress_percent'] == 15 + assert [step['stage'] for step in ctx.progress['steps']] == ['checking', 'downloading', 'validating'] + assert all(step['finished_at'] is not None for step in ctx.progress['steps']) + assert ctx.progress['status'] == 'failed' + assert 'private-token' not in str(ctx.progress) + + +@pytest.mark.asyncio +async def test_install_progress_visible_while_download_is_running(env): + import asyncio + + async with env.engine.begin() as conn: + await conn.execute(sa.delete(PluginSetting)) + started, release = asyncio.Event(), asyncio.Event() + + async def install(source, info, task_context): + task_context.set_current_action('downloading plugin package') + task_context.metadata.update(download_current=123, download_total=456) + started.set() + await release.wait() + raise RuntimeError('private-token') + + env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install) + response = await env.svc.execute(context(), {'confirmed': True, 'all': True, 'install_plugins': True}) + task = env.ap.task_mgr.get_task_by_id(response['task_id']) + try: + await asyncio.wait_for(started.wait(), 3) + await asyncio.sleep(0.25) + progress = task.task_context.metadata['installations'][0] + assert progress['status'] == 'installing' + assert progress['download_current'] == 123 + assert progress['stage'] == 'downloading' + finally: + release.set() + await task.task + assert len(task.task_context.metadata['installations']) == 1 + assert progress['status'] == 'failed' + assert 'private-token' not in str(task.task_context.to_dict()) + + +@pytest.mark.asyncio +async def test_missing_marketplace_release_has_specific_migration_result(env): + from langbot.pkg.plugin.connector import MarketplacePluginVersionNotFoundError + + async with env.engine.begin() as conn: + await conn.execute(sa.delete(PluginSetting)) + env.ap.plugin_connector.install_plugin = AsyncMock( + side_effect=MarketplacePluginVersionNotFoundError('private-token') + ) + _, metadata = await execute_all(env) + assert all(r['code'] == 'plugin_version_unavailable' for r in metadata['results']) + assert metadata['installations'][0]['code'] == 'plugin_version_unavailable' + assert 'private-token' not in str(metadata) diff --git a/tests/unit_tests/api/service/test_pipeline_migration_resources.py b/tests/unit_tests/api/service/test_pipeline_migration_resources.py index d70adbfdb..aa169a569 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration_resources.py +++ b/tests/unit_tests/api/service/test_pipeline_migration_resources.py @@ -168,3 +168,76 @@ async def test_local_descriptor_migrates_through_detached_task(local, monkeypatc assert configs['one'] == plan['config'] assert len(snapshots) == 1 and snapshots[0]['source_snapshot']['config'] == base.SOURCE env.ap.pipeline_mgr.publish_pipeline.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'template', + [ + None, + '{global}', + '{launcher_type}_{launcher_id}', + '{launcher_type}_{launcher_id}_{sender_id}', + '{launcher_type}_{launcher_id}_{conversation_id}', + '{query_id}', + '{bot_id}_{launcher_type}_{launcher_id}', + 'project-{project}', + ], +) +@pytest.mark.parametrize('install_plugins', [False, True]) +async def test_box_templates_roundtrip_in_both_migration_modes(local, monkeypatch, template, install_plugins): + from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline + + env, descriptor, _, _ = local + monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline) + section = {'model': 'model', 'prompt': [], 'enable-all-tools': False, 'tools': []} + if template is not None: + section['box-session-id-template'] = template + source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': section}} + plan = plan_legacy_pipeline(source) + descriptor.plugin_version = plan['target_plugin']['version'] + async with env.engine.begin() as conn: + await conn.execute( + sa.update(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'one').values(config=source) + ) + await conn.execute(sa.delete(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'two')) + if install_plugins: + await conn.execute( + sa.insert(base.PluginSetting).values( + workspace_uuid=WS, + plugin_author='langbot-team', + plugin_name='LocalAgent', + enabled=True, + ) + ) + if not install_plugins: + env.ap.runner_registry.list_runners.side_effect = AssertionError('Data-only must work offline') + env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=AssertionError('Data-only must not install')) + _, result = await base.execute_all(env, install_plugins=install_plugins) + assert result['results'] == [ + { + 'pipeline_uuid': 'one', + 'state': 'migrated', + 'code': None if install_plugins else 'data_only', + } + ] + configs, snapshots = await base.rows(env) + saved = configs['one']['ai']['runner_config'][plan['target_runner_id']] + effective = {field['name']: field['default'] for field in descriptor.config_schema if 'default' in field} + effective.update(saved) + assert effective['box-enabled'] is True + assert effective['box-session-id-template'] == (template or '{launcher_type}_{launcher_id}') + assert snapshots[0]['source_snapshot']['config'] == source + assert 'box-session-id-template' not in configs['one']['ai'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('allow_custom', [False, None, 'true']) +async def test_select_requires_explicit_custom_support(local, allow_custom): + env, descriptor, config, plan = local + field = next(f for f in descriptor.config_schema if f['name'] == 'box-session-id-template') + field['allow_custom'] = allow_custom + config['box-session-id-template'] = '{bot_id}_{launcher_id}' + async with env.pm.tenant_scope(WS): + with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'): + await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) diff --git a/tests/unit_tests/pipeline/test_legacy_config_migration.py b/tests/unit_tests/pipeline/test_legacy_config_migration.py index dccbc3adb..1658354f8 100644 --- a/tests/unit_tests/pipeline/test_legacy_config_migration.py +++ b/tests/unit_tests/pipeline/test_legacy_config_migration.py @@ -992,3 +992,13 @@ def test_local_box_reuse_templates_are_preserved_for_plugin(template): result = plan(source) assert result['state'] != 'blocked', result assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template + + +@pytest.mark.parametrize( + 'template', ['', ' ', '{}', '{0}', '{sender_id', '{sender_id!r}', '{query_id:04}', '{actor.id}', '{actor[id]}'] +) +def test_invalid_box_templates_are_reported_before_migration(template): + source = source_for('local-agent') + source['ai']['local-agent']['box-session-id-template'] = template + result = plan(source) + assert_block(result, 'local.box_template_invalid', 'ai.local-agent.box-session-id-template') diff --git a/tests/unit_tests/pipeline/test_migration_current_shape.py b/tests/unit_tests/pipeline/test_migration_current_shape.py index 7d976ed5f..f6189633d 100644 --- a/tests/unit_tests/pipeline/test_migration_current_shape.py +++ b/tests/unit_tests/pipeline/test_migration_current_shape.py @@ -62,4 +62,6 @@ def test_empty_box_template_requires_correction(): source['ai']['local-agent']['box-session-id-template'] = '' result = plan_legacy_pipeline(source) assert result['state'] == 'blocked' - assert {'code': 'invalid_type', 'field': 'ai.local-agent.box-session-id-template'} in result['blockers'] + assert {'code': 'local.box_template_invalid', 'field': 'ai.local-agent.box-session-id-template'} in result[ + 'blockers' + ] diff --git a/tests/unit_tests/plugin/test_connector_pure.py b/tests/unit_tests/plugin/test_connector_pure.py index 5cc863940..337ec16a1 100644 --- a/tests/unit_tests/plugin/test_connector_pure.py +++ b/tests/unit_tests/plugin/test_connector_pure.py @@ -163,3 +163,32 @@ async def test_marketplace_response_reader_is_bounded(): with pytest.raises(ValueError, match='exceeds'): await _read_httpx_response_limited(response, max_bytes=4) + + +@pytest.mark.asyncio +async def test_marketplace_missing_release_is_classified_without_upstream_message(): + from langbot.pkg.plugin.connector import _marketplace_get, MarketplacePluginVersionNotFoundError + + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda r: httpx.Response(500, json={'msg': 'plugin version not found: record not found private-token'}) + ) + ) as client: + with pytest.raises(MarketplacePluginVersionNotFoundError) as exc: + await _marketplace_get(client, 'https://market.test/download', max_bytes=4096) + assert 'private-token' not in str(exc.value) + + +@pytest.mark.asyncio +async def test_marketplace_download_reports_byte_progress(): + from langbot.pkg.plugin.connector import _marketplace_get + from langbot.pkg.core.taskmgr import TaskContext + + ctx = TaskContext.new() + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda r: httpx.Response(200, content=b'x' * 1024)) + ) as client: + status, body = await _marketplace_get(client, 'https://market.test/download', max_bytes=2048, task_context=ctx) + assert status == 200 and len(body) == 1024 + assert ctx.metadata['download_current'] == ctx.metadata['download_total'] == 1024 + assert ctx.metadata['download_speed'] > 0 diff --git a/uv.lock b/uv.lock index f20c2be6f..102b33527 100644 --- a/uv.lock +++ b/uv.lock @@ -2180,7 +2180,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=94c098ee0d1c4535043d9bf4a74c25f90849bc41" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2251,7 +2251,7 @@ dev = [ [[package]] name = "langbot-plugin" version = "0.6.0b2" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=94c098ee0d1c4535043d9bf4a74c25f90849bc41#94c098ee0d1c4535043d9bf4a74c25f90849bc41" } +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9#a0ee992ed6b5d48c10c0390d450ab6e9ac23eab9" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, diff --git a/web/src/app/home/pipelines/MigrationInstallProgress.tsx b/web/src/app/home/pipelines/MigrationInstallProgress.tsx new file mode 100644 index 000000000..e36bf765d --- /dev/null +++ b/web/src/app/home/pipelines/MigrationInstallProgress.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + CheckCircle2, + ChevronDown, + Loader2, + Puzzle, + XCircle, +} from 'lucide-react'; +import type { MigrationInstallation } from '@/app/infra/entities/api/pipeline-migration'; +import type { PluginV4 } from '@/app/infra/entities/plugin'; +import { getCloudServiceClientSync } from '@/app/infra/http'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; + +const stageKeys: Record = { + checking: 'pipelineMigration.checkingPlugin', + downloading: 'plugins.installProgress.downloading', + validating: 'plugins.installProgress.validating', + preparing: 'pipelineMigration.preparingPlugin', + installing_deps: 'plugins.installProgress.installingDeps', + activating: 'plugins.installProgress.initializing', + refreshing: 'plugins.installProgress.activating', + done: 'plugins.installProgress.completed', +}; + +function bytes(value: number) { + return value < 1024 * 1024 + ? `${(value / 1024).toFixed(1)} KB` + : `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +function InstallationPluginIdentity({ item }: { item: MigrationInstallation }) { + const { t } = useTranslation(); + const [plugin, setPlugin] = useState(null); + + useEffect(() => { + let active = true; + void getCloudServiceClientSync() + .getPluginDetail(item.author, item.name) + .then(({ plugin }) => { + if (active) setPlugin(plugin); + }) + .catch(() => { + // Marketplace metadata is optional and must not interrupt installation. + }); + return () => { + active = false; + }; + }, [item.author, item.name]); + + const name = (plugin?.label && extractI18nObject(plugin.label)) || item.name; + const description = + (plugin?.description && extractI18nObject(plugin.description)) || + t('market.noDescription'); + const iconURL = getCloudServiceClientSync().resolveMarketplaceIconURL( + 'plugin', + item.author, + item.name, + plugin?.icon, + ); + + return ( +
+ + + + + + +
+
+ + {name} + + + {item.version} + +
+

+ {description} +

+
+
+ ); +} + +export default function MigrationInstallProgress({ + installations, +}: { + installations: MigrationInstallation[]; +}) { + const { t } = useTranslation(); + if (!installations.length) return null; + return ( +
+ {installations.map((item) => { + const failed = item.status === 'failed'; + const done = item.status === 'completed'; + const Icon = failed ? XCircle : done ? CheckCircle2 : Loader2; + const label = t( + stageKeys[item.stage] ?? 'pipelineMigration.installing', + ); + const end = item.finished_at ?? item.updated_at; + const seconds = (start: number, finish: number) => + `${Math.max(0, finish - start).toFixed(1)} s`; + return ( + + +
+ + + {failed ? t('plugins.installProgress.failed') : label} + + + {seconds(item.started_at, end)} + +
+ {!failed && !done && ( + + )} + {failed && ( +

+ {t( + item.code === 'plugin_runtime_unavailable' + ? 'pipelineMigration.notices.runtimeUnavailable' + : `pipelineMigration.installErrors.${item.code}`, + { defaultValue: t('pipelineMigration.installFailed') }, + )} +

+ )} + + + + +

+ {item.author}/{item.name} +

+ {item.steps.map((step, index) => ( +
+ + {t(stageKeys[step.stage] ?? 'pipelineMigration.installing')} + + + {seconds(step.started_at, step.finished_at ?? end)} + +
+ ))} + {item.download_current !== undefined && ( +

+ {t('pipelineMigration.downloaded', { + size: bytes(item.download_current), + })} + {item.download_total + ? ` / ${bytes(item.download_total)}` + : ''} + {item.status === 'installing' && + item.stage === 'downloading' && + item.download_speed + ? ` · ${bytes(item.download_speed)}/s` + : ''} +

+ )} + {item.deps_total !== undefined && ( +

+ {t('plugins.installProgress.depsInfo', { + count: item.deps_total, + })} +

+ )} +
+
+ ); + })} +
+ ); +} diff --git a/web/src/app/home/pipelines/PipelineMigration.tsx b/web/src/app/home/pipelines/PipelineMigration.tsx index 853d46f5d..892022f37 100644 --- a/web/src/app/home/pipelines/PipelineMigration.tsx +++ b/web/src/app/home/pipelines/PipelineMigration.tsx @@ -3,9 +3,11 @@ import { useTranslation } from 'react-i18next'; import { AlertTriangle, ChevronDown, Loader2 } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore'; +import MigrationInstallProgress from './MigrationInstallProgress'; import { migrationIssueKey } from './pipeline-migration-issues'; import type { CurrentWorkspace } from '@/app/infra/entities/workspace'; import type { + MigrationInstallation, PipelineMigrationIssue, PipelineMigrationPreview, PipelineMigrationResult, @@ -70,6 +72,9 @@ export default function PipelineMigration({ const [phase, setPhase] = useState('migrating'); const [dataOnly, setDataOnly] = useState(false); const [status, setStatus] = useState('idle'); + const [installations, setInstallations] = useState( + [], + ); const [results, setResults] = useState([]); const active = useRef(false); const submitting = useRef(false); @@ -135,6 +140,12 @@ export default function PipelineMigration({ }, [refresh]); const busy = status === 'submitting' || status === 'running'; + const completed = + status === 'finished' && + results.length > 0 && + results.every((item) => + ['migrated', 'already_current'].includes(item.state), + ); const rows = preview?.items ?? []; const count = rows.filter( (item) => !['already_current', 'not_legacy'].includes(item.state), @@ -142,6 +153,17 @@ export default function PipelineMigration({ function renderIssue(issue: PipelineMigrationIssue, warning = false) { // Unknown server codes use localized fallbacks, never raw upstream messages. + if ( + [ + 'plugin_version_unavailable', + 'plugin_download_timeout', + 'plugin_marketplace_unavailable', + 'plugin_download_failed', + 'dependency_prepare_failed', + 'plugin_launch_failed', + ].includes(issue.code) + ) + return {t(`pipelineMigration.installErrors.${issue.code}`)}; if (issue.code === 'plugin_install_failed') return {t('pipelineMigration.installFailed')}; const key = migrationIssueKey(issue.code); @@ -169,6 +191,7 @@ export default function PipelineMigration({ let items = rows.filter( (item) => !['already_current', 'not_legacy'].includes(item.state), ); + setInstallations([]); setDataOnly(!installPlugins); setPhase(installPlugins ? 'installing' : 'migrating'); submitting.current = true; @@ -267,6 +290,8 @@ export default function PipelineMigration({ }, ); setResults(scopedResults); + if (Array.isArray(metadata.installations)) + setInstallations(metadata.installations as MigrationInstallation[]); if (metadata.phase === 'installing' || metadata.phase === 'migrating') setPhase(metadata.phase); if (task.runtime.done) { @@ -309,7 +334,7 @@ export default function PipelineMigration({ function changeOpen(next: boolean) { setOpen(next); if (!submitting.current) { - if (next) void refresh(status === 'idle'); + if (next) void refresh(); } } @@ -386,7 +411,17 @@ export default function PipelineMigration({ {t('pipelineMigration.detected', { count })}

)} - {!busy && (count > 0 || results.length > 0) && ( + + {busy && results.length > 0 && ( +

+ {t('pipelineMigration.processed', { + completed: results.filter((r) => r.state !== 'pending') + .length, + total: results.length, + })} +

+ )} + {(count > 0 || results.length > 0) && ( - -

- {t('pipelineMigration.dataOnlyHint')} -

- {(status !== 'idle' || previewError) && ( + {completed ? ( + + ) : ( + !busy && ( + <> + - )} - +

+ {t('pipelineMigration.dataOnlyHint')} +

+ + ) )}