From 60606e1997eae83a36b74ad7e5b282ede537483f Mon Sep 17 00:00:00 2001
From: RockChinQ
Date: Fri, 18 Sep 2026 13:10:32 +0800
Subject: [PATCH] fix(migration): improve runner migration and plugin
installation feedback
---
docs/pipeline-migration-config-map.zh-CN.md | 6 +-
pyproject.toml | 2 +-
.../api/http/service/pipeline_migration.py | 133 +++++++++--
.../pkg/pipeline/legacy_config_migration.py | 14 +-
src/langbot/pkg/plugin/connector.py | 45 ++--
src/langbot/pkg/plugin/errors.py | 24 ++
.../pipeline_migration_local_schema.json | 149 ++++++++++--
.../api/service/test_pipeline_migration.py | 87 +++++++
.../test_pipeline_migration_resources.py | 73 ++++++
.../pipeline/test_legacy_config_migration.py | 10 +
.../pipeline/test_migration_current_shape.py | 4 +-
.../unit_tests/plugin/test_connector_pure.py | 29 +++
uv.lock | 4 +-
.../pipelines/MigrationInstallProgress.tsx | 213 ++++++++++++++++++
.../app/home/pipelines/PipelineMigration.tsx | 102 ++++++---
.../pipelines/pipeline-migration-issues.ts | 6 +-
.../infra/entities/api/pipeline-migration.ts | 19 ++
web/src/components/ui/progress.tsx | 1 +
.../i18n/locales/pipeline-migration/en-US.ts | 32 ++-
.../i18n/locales/pipeline-migration/es-ES.ts | 32 ++-
.../i18n/locales/pipeline-migration/ja-JP.ts | 33 ++-
.../i18n/locales/pipeline-migration/ru-RU.ts | 32 ++-
.../i18n/locales/pipeline-migration/th-TH.ts | 33 ++-
.../i18n/locales/pipeline-migration/vi-VN.ts | 32 ++-
.../locales/pipeline-migration/zh-Hans.ts | 28 ++-
.../locales/pipeline-migration/zh-Hant.ts | 29 ++-
web/tests/e2e/pipeline-migration.spec.ts | 150 +++++++++++-
.../unit/pipeline-migration-notices.test.mjs | 6 +
28 files changed, 1197 insertions(+), 131 deletions(-)
create mode 100644 src/langbot/pkg/plugin/errors.py
create mode 100644 web/src/app/home/pipelines/MigrationInstallProgress.tsx
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) && (