feat: add verified manual pipeline migration for plugin runners

This commit is contained in:
RockChinQ
2026-09-16 18:13:11 +00:00
parent 273b1ea3cf
commit 8c119bc4b6
97 changed files with 16662 additions and 123 deletions
@@ -0,0 +1,176 @@
[
{
"name": "base-url",
"label": {
"en_US": "Base URL",
"zh_Hans": "\u57fa\u7840 URL"
},
"type": "string",
"required": true,
"default": "http://localhost:7860"
},
{
"name": "api-key",
"label": {
"en_US": "API Key",
"zh_Hans": "API \u5bc6\u94a5"
},
"type": "secret",
"required": true,
"default": ""
},
{
"name": "flow-id",
"label": {
"en_US": "Flow ID",
"zh_Hans": "\u6d41\u7a0b ID"
},
"type": "string",
"required": true,
"default": ""
},
{
"name": "advanced-settings",
"label": {
"en_US": "Advanced Settings",
"zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e"
},
"description": {
"en_US": "Show input, output, and flow tweak controls.",
"zh_Hans": "\u663e\u793a\u8f93\u5165\u3001\u8f93\u51fa\u548c\u6d41\u7a0b tweak \u8c03\u4f18\u9009\u9879\u3002"
},
"type": "boolean",
"required": false,
"default": false
},
{
"name": "input-type",
"label": {
"en_US": "Input Type",
"zh_Hans": "\u8f93\u5165\u7c7b\u578b"
},
"type": "string",
"required": false,
"default": "chat",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "output-type",
"label": {
"en_US": "Output Type",
"zh_Hans": "\u8f93\u51fa\u7c7b\u578b"
},
"type": "string",
"required": false,
"default": "chat",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "tweaks",
"label": {
"en_US": "Tweaks",
"zh_Hans": "\u8c03\u6574\u53c2\u6570"
},
"type": "json",
"required": false,
"default": "{}",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "langbot-assets-enabled",
"label": {
"en_US": "Enable LangBot Assets",
"zh_Hans": "\u542f\u7528 LangBot \u8d44\u4ea7"
},
"type": "boolean",
"required": false,
"default": false
},
{
"name": "langbot-assets-gateway-host",
"label": {
"en_US": "LangBot Asset Gateway Host",
"zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u76d1\u542c\u5730\u5740"
},
"type": "string",
"required": false,
"default": "0.0.0.0",
"show_if": {
"field": "langbot-assets-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "langbot-assets-gateway-port",
"label": {
"en_US": "LangBot Asset Gateway Port",
"zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u7aef\u53e3"
},
"type": "integer",
"required": false,
"default": 8765,
"show_if": {
"field": "langbot-assets-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "langbot-assets-gateway-request-timeout",
"label": {
"en_US": "LangBot Asset Gateway Request Timeout",
"zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u8bf7\u6c42\u8d85\u65f6"
},
"type": "integer",
"required": false,
"default": 60,
"show_if": {
"field": "langbot-assets-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "langbot-assets-token-ttl",
"label": {
"en_US": "LangBot Asset Token TTL",
"zh_Hans": "LangBot \u8d44\u4ea7\u4ee4\u724c\u6709\u6548\u671f"
},
"type": "integer",
"required": false,
"default": 3600,
"show_if": {
"field": "langbot-assets-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "langbot-assets-input-name",
"label": {
"en_US": "LangBot Asset Token Input Name",
"zh_Hans": "LangBot \u8d44\u4ea7\u4ee4\u724c\u8f93\u5165\u7ec4\u4ef6\u540d"
},
"type": "string",
"required": false,
"default": "langbot_asset_run_token",
"show_if": {
"field": "langbot-assets-enabled",
"operator": "eq",
"value": true
}
}
]
@@ -0,0 +1,94 @@
[
{
"name": "api-base",
"type": "string",
"required": true,
"default": "http://127.0.0.1:2026"
},
{
"name": "api-key",
"type": "secret",
"required": false,
"default": ""
},
{
"name": "auth-header",
"type": "secret",
"required": false,
"default": ""
},
{
"name": "assistant-id",
"type": "string",
"required": true,
"default": "lead_agent"
},
{
"name": "advanced-settings",
"type": "boolean",
"required": false,
"default": false
},
{
"name": "model-name",
"type": "string",
"required": false,
"default": "",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "thinking-enabled",
"type": "boolean",
"required": false,
"default": false
},
{
"name": "plan-mode",
"type": "boolean",
"required": false,
"default": false
},
{
"name": "subagent-enabled",
"type": "boolean",
"required": false,
"default": false
},
{
"name": "max-concurrent-subagents",
"type": "integer",
"required": false,
"default": 3,
"show_if": {
"field": "subagent-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "timeout",
"type": "integer",
"required": false,
"default": 300,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "recursion-limit",
"type": "integer",
"required": false,
"default": 1000,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
}
]
@@ -0,0 +1,326 @@
{
"usages": [
"agent"
],
"config": [
{
"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"
},
"label": {
"en_US": "Model",
"zh_Hans": "\u6a21\u578b"
},
"type": "model-fallback-selector",
"required": true,
"default": {
"primary": "",
"fallbacks": [],
"reasoning": {}
}
},
{
"name": "prompt",
"label": {
"en_US": "Prompt",
"zh_Hans": "\u63d0\u793a\u8bcd"
},
"type": "prompt-editor",
"required": true,
"default": [
{
"role": "system",
"content": "You are a helpful assistant."
}
]
},
{
"name": "knowledge-bases",
"label": {
"en_US": "Knowledge Bases",
"zh_Hans": "\u77e5\u8bc6\u5e93"
},
"type": "knowledge-base-multi-selector",
"required": false,
"default": []
},
{
"name": "advanced-settings",
"label": {
"en_US": "Advanced Settings",
"zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e"
},
"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"
},
"type": "boolean",
"required": false,
"default": false
},
{
"name": "date-grounding",
"label": {
"en_US": "Current Date Grounding",
"zh_Hans": "\u5f53\u524d\u65e5\u671f\u951a\u5b9a"
},
"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"
},
"type": "boolean",
"required": false,
"default": true,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "timeout",
"label": {
"en_US": "Timeout",
"zh_Hans": "\u6267\u884c\u8d85\u65f6"
},
"type": "integer",
"required": false,
"default": 300,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "remove-think",
"label": {
"en_US": "Remove Thinking Output",
"zh_Hans": "\u79fb\u9664\u601d\u8003\u5185\u5bb9"
},
"type": "boolean",
"required": false,
"default": false,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "retrieval-top-k",
"label": {
"en_US": "Retrieval Top K",
"zh_Hans": "\u77e5\u8bc6\u5e93\u68c0\u7d22\u6570\u91cf"
},
"type": "integer",
"required": false,
"default": 5,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "rerank-model",
"label": {
"en_US": "Rerank Model",
"zh_Hans": "\u91cd\u6392\u5e8f\u6a21\u578b"
},
"type": "rerank-model-selector",
"required": false,
"default": "",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "rerank-top-k",
"label": {
"en_US": "Rerank Top K",
"zh_Hans": "\u91cd\u6392\u5e8f\u4fdd\u7559\u6570\u91cf"
},
"type": "integer",
"required": false,
"default": 5,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "max-tool-iterations",
"label": {
"en_US": "Max Tool Iterations",
"zh_Hans": "\u6700\u5927\u5de5\u5177\u8c03\u7528\u8f6e\u6570"
},
"type": "integer",
"required": false,
"default": 100,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"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"
},
"label": {
"en_US": "Tool Execution Mode",
"zh_Hans": "\u5de5\u5177\u6267\u884c\u6a21\u5f0f"
},
"type": "select",
"required": false,
"default": "parallel",
"options": [
{
"name": "parallel",
"label": {
"en_US": "Parallel",
"zh_Hans": "\u5e76\u884c"
}
},
{
"name": "serial",
"label": {
"en_US": "Serial",
"zh_Hans": "\u4e32\u884c"
}
}
],
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "max-tool-result-chars",
"label": {
"en_US": "Max Tool Result Characters",
"zh_Hans": "\u6700\u5927\u5de5\u5177\u7ed3\u679c\u5b57\u7b26\u6570"
},
"type": "integer",
"required": false,
"default": 20000,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "context-history-fetch-limit",
"label": {
"en_US": "History Fetch Limit",
"zh_Hans": "\u5386\u53f2\u6d88\u606f\u62c9\u53d6\u6570\u91cf"
},
"type": "integer",
"required": false,
"default": 50,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "context-window-tokens",
"label": {
"en_US": "Context Window Tokens",
"zh_Hans": "\u4e0a\u4e0b\u6587\u7a97\u53e3 Token \u6570"
},
"type": "integer",
"required": false,
"default": 200000,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "context-reserve-tokens",
"label": {
"en_US": "Reserved Output Tokens",
"zh_Hans": "\u8f93\u51fa\u4fdd\u7559 Token \u6570"
},
"type": "integer",
"required": false,
"default": 16384,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "context-keep-recent-tokens",
"label": {
"en_US": "Recent Context Tokens",
"zh_Hans": "\u6700\u8fd1\u4e0a\u4e0b\u6587 Token \u6570"
},
"type": "integer",
"required": false,
"default": 20000,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "context-summary-tokens",
"label": {
"en_US": "Summary Tokens",
"zh_Hans": "\u6458\u8981 Token \u6570"
},
"type": "integer",
"required": false,
"default": 8000,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
}
],
"capabilities": {
"streaming": true,
"tool_calling": true,
"knowledge_retrieval": true,
"multimodal_input": true,
"skill_authoring": true,
"interrupt": true,
"steering": true
},
"permissions": {
"models": [
"count_tokens",
"invoke",
"stream",
"rerank"
],
"tools": [
"detail",
"call"
],
"knowledge_bases": [
"list",
"retrieve"
],
"history": [
"page"
]
}
}
@@ -0,0 +1,154 @@
[
{
"name": "base-url",
"label": {
"en_US": "Base URL",
"zh_Hans": "\u57fa\u7840 URL"
},
"description": {
"en_US": "The WeKnora API base URL, including /api/v1.",
"zh_Hans": "WeKnora API \u57fa\u7840 URL\uff0c\u5305\u542b /api/v1\u3002"
},
"type": "string",
"required": true,
"default": "http://localhost:8080/api/v1"
},
{
"name": "api-key",
"label": {
"en_US": "API Key",
"zh_Hans": "API \u5bc6\u94a5"
},
"description": {
"en_US": "API key generated from WeKnora Settings -> API Keys.",
"zh_Hans": "\u4ece WeKnora \u8bbe\u7f6e -> API Keys \u751f\u6210\u7684 API \u5bc6\u94a5\u3002"
},
"type": "secret",
"required": true,
"default": ""
},
{
"name": "app-type",
"label": {
"en_US": "App Type",
"zh_Hans": "\u5e94\u7528\u7c7b\u578b"
},
"type": "select",
"required": true,
"default": "agent",
"options": [
{
"name": "agent",
"label": {
"en_US": "Agent (Smart Reasoning)",
"zh_Hans": "Agent\uff08\u667a\u80fd\u63a8\u7406\uff09"
}
},
{
"name": "chat",
"label": {
"en_US": "Chat (Knowledge Base RAG)",
"zh_Hans": "\u804a\u5929\uff08\u77e5\u8bc6\u5e93 RAG\uff09"
}
}
]
},
{
"name": "agent-id",
"label": {
"en_US": "Agent ID",
"zh_Hans": "\u667a\u80fd\u4f53 ID"
},
"description": {
"en_US": "When omitted, chat uses builtin-quick-answer and agent uses builtin-smart-reasoning. An explicit empty value lets the server choose. Saved IDs apply in both modes.",
"zh_Hans": "\u672a\u8bbe\u7f6e\u65f6\uff0c\u804a\u5929\u4f7f\u7528 builtin-quick-answer\uff0c\u667a\u80fd\u4f53\u4f7f\u7528 builtin-smart-reasoning\u3002\u663e\u5f0f\u7559\u7a7a\u5219\u7531\u670d\u52a1\u7aef\u9009\u62e9\u3002\u5df2\u4fdd\u5b58\u7684 ID \u5728\u4e24\u79cd\u6a21\u5f0f\u4e0b\u5747\u751f\u6548\u3002"
},
"type": "string",
"required": false
},
{
"name": "knowledge-base-ids",
"label": {
"en_US": "Knowledge Base IDs",
"zh_Hans": "\u77e5\u8bc6\u5e93 ID \u5217\u8868"
},
"description": {
"en_US": "Remote WeKnora knowledge base IDs used in both modes, not LangBot knowledge base UUIDs. Values and order are preserved.",
"zh_Hans": "\u4e24\u79cd\u6a21\u5f0f\u5747\u53ef\u4f7f\u7528\u7684\u8fdc\u7aef WeKnora \u77e5\u8bc6\u5e93 ID\uff0c\u4e0d\u662f LangBot \u77e5\u8bc6\u5e93 UUID\u3002\u4fdd\u7559\u539f\u503c\u548c\u987a\u5e8f\u3002"
},
"type": "array[string]",
"required": false,
"default": []
},
{
"name": "web-search-enabled",
"label": {
"en_US": "Enable Web Search",
"zh_Hans": "\u542f\u7528\u7f51\u7edc\u641c\u7d22"
},
"description": {
"en_US": "Whether to enable web search in agent mode.",
"zh_Hans": "\u662f\u5426\u5728 Agent \u6a21\u5f0f\u4e0b\u542f\u7528\u7f51\u7edc\u641c\u7d22\u3002"
},
"type": "boolean",
"required": false,
"default": false,
"show_if": {
"field": "app-type",
"operator": "eq",
"value": "agent"
}
},
{
"name": "advanced-settings",
"label": {
"en_US": "Advanced Settings",
"zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e"
},
"description": {
"en_US": "Show timeout and fallback prompt controls.",
"zh_Hans": "\u663e\u793a\u8d85\u65f6\u548c\u56de\u9000\u63d0\u793a\u8bcd\u8c03\u4f18\u9009\u9879\u3002"
},
"type": "boolean",
"required": false,
"default": false
},
{
"name": "timeout",
"label": {
"en_US": "Timeout",
"zh_Hans": "\u8d85\u65f6\u65f6\u95f4"
},
"description": {
"en_US": "Request timeout in seconds.",
"zh_Hans": "\u8bf7\u6c42\u8d85\u65f6\u65f6\u95f4\uff08\u79d2\uff09\u3002"
},
"type": "integer",
"required": false,
"default": 120,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "base-prompt",
"label": {
"en_US": "Base Prompt",
"zh_Hans": "\u57fa\u7840\u63d0\u793a\u8bcd"
},
"description": {
"en_US": "Default prompt when the user message is empty.",
"zh_Hans": "\u7528\u6237\u6d88\u606f\u4e3a\u7a7a\u65f6\u4f7f\u7528\u7684\u9ed8\u8ba4\u63d0\u793a\u8bcd\u3002"
},
"type": "string",
"required": false,
"default": "\u8bf7\u56de\u7b54\u7528\u6237\u7684\u95ee\u9898\u3002",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
}
]
@@ -0,0 +1,734 @@
"""Manual migration boundary tests; all configs are synthetic."""
import asyncio
import copy
import importlib
import importlib.util
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.authz import PermissionDeniedError, permissions_for_role
from langbot.pkg.api.http.context import RequestContext, PrincipalContext, PrincipalType, WorkspaceContext
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
from langbot.pkg.entity.persistence.plugin import PluginSetting
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import Workspace
from langbot.pkg.persistence.mgr import PersistenceManager
from langbot.pkg.core.taskmgr import AsyncTaskManager
MODULE = 'langbot.pkg.api.http.service.pipeline_migration'
RID = 'plugin:langbot-team/TestAgent/default'
WS = '00000000-0000-0000-0000-000000000001'
OTHER = '00000000-0000-0000-0000-000000000002'
SOURCE = {'ai': {'runner': 'test', 'secret': 'synthetic-secret'}, 'output': {'keep': True}}
def module():
assert importlib.util.find_spec(MODULE) is not None, 'manual migration service is missing'
return importlib.import_module(MODULE)
def context(role='developer'):
return RequestContext(
'instance',
1,
'request',
'user_token',
PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account'),
WorkspaceContext(WS, 'membership', role, permissions_for_role(role)),
)
def planner(config, extensions_preferences=None):
base = dict(
state='already_current',
legacy_runner=None,
target_runner_id=None,
target_plugin=None,
changed_paths=[],
blockers=[],
warnings=[],
)
if isinstance(config.get('ai', {}).get('runner'), str):
target = copy.deepcopy(config)
target['ai'] = {'runner': {'id': RID}, 'runner_config': {RID: {'api_key': config['ai']['secret']}}}
base.update(
state='ready',
legacy_runner='test',
target_runner_id=RID,
target_plugin={'author': 'langbot-team', 'name': 'TestAgent', 'version': '1.0'},
changed_paths=['ai.runner', 'ai.runner_config'],
config=target,
)
return base
class PM(PersistenceManager):
def __init__(self, engine):
super().__init__(NS())
self.db = NS(get_engine=lambda: engine)
@pytest.fixture
async def env(tmp_path, monkeypatch):
m = module()
monkeypatch.setattr(m, 'plan_legacy_pipeline', planner)
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "migration.db"}')
async with engine.begin() as conn:
names = {
'users',
'workspaces',
'workspace_memberships',
'workspace_execution_states',
'legacy_pipelines',
'plugin_settings',
'pipeline_migration_snapshots',
'agent_interaction',
}
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[Base.metadata.tables[n] for n in names]))
await conn.execute(
sa.insert(User).values(
uuid='account',
user='test@example.invalid',
normalized_email='test@example.invalid',
password='synthetic-not-a-password',
)
)
await conn.execute(
sa.insert(Workspace),
[
dict(uuid=ws, instance_uuid='instance', name=ws, slug=ws, source='cloud_projection')
for ws in (WS, OTHER)
],
)
await conn.execute(
sa.insert(LegacyPipeline),
[
dict(
uuid=pid,
workspace_uuid=ws,
name=pid,
description='kept',
for_version='4.10',
stages=[],
config=SOURCE,
extensions_preferences={'enable_all_plugins': True},
)
for pid, ws in [('one', WS), ('two', WS), ('foreign', OTHER)]
],
)
await conn.execute(
sa.insert(PluginSetting).values(
workspace_uuid=WS,
plugin_author='langbot-team',
plugin_name='TestAgent',
enabled=True,
install_info={'version': '1.0'},
)
)
pm = PM(engine)
binding = NS(instance_uuid='instance', workspace_uuid=WS, placement_generation=1)
access = NS(
workspace=NS(uuid=WS),
membership=NS(uuid='membership', role='developer', projection_revision=0),
execution=binding,
)
ap = NS(
persistence_mgr=pm,
event_loop=asyncio.get_running_loop(),
instance_config=NS(data={}),
workspace_service=NS(get_execution_binding=AsyncMock(return_value=binding)),
workspace_collaboration_service=NS(resolve_account_workspace=AsyncMock(return_value=access)),
pipeline_mgr=NS(prepare_pipeline=AsyncMock(return_value='candidate'), publish_pipeline=Mock()),
sess_mgr=NS(session_list=[]),
runner_registry=NS(
list_runners=AsyncMock(
return_value=[
NS(
id=RID,
usages=['agent'],
plugin_version='1.0',
config_schema=[{'name': 'api_key', 'type': 'string', 'required': True}],
)
]
)
),
plugin_connector=NS(require_workspace_context=AsyncMock()),
)
ap.task_mgr = AsyncTaskManager(ap)
svc = m.PipelineMigrationService(ap)
yield NS(m=m, ap=ap, svc=svc, engine=engine, pm=pm, access=access)
await ap.task_mgr.wait_all()
await engine.dispose()
async def selection(env, ids=('one',)):
preview = await env.svc.preview(context())
return {
'confirmed': True,
'items': [
{k: item[k] for k in ('pipeline_uuid', 'preview_token')}
for item in preview['items']
if item['pipeline_uuid'] in ids
],
}
async def execute(env, body=None):
result = await env.svc.execute(context(), body or await selection(env))
task = env.ap.task_mgr.get_task_by_id(result['task_id'])
await task.task
return task
async def rows(env):
async with env.engine.connect() as conn:
configs = dict((await conn.execute(sa.select(LegacyPipeline.uuid, LegacyPipeline.config))).all())
backups = (await conn.execute(sa.select(env.m.PipelineMigrationSnapshot))).mappings().all()
return configs, backups
def test_strict_confirmation_and_selection():
m = module()
valid = {'confirmed': True, 'items': [{'pipeline_uuid': 'one', 'preview_token': 'token'}]}
assert m.validate_execute_request(valid) == valid['items']
for body in [
None,
{},
{**valid, 'confirmed': 'true'},
{**valid, 'confirmed': 1},
{**valid, 'confirmed': False},
{**valid, 'workspace_uuid': WS},
{**valid, 'items': []},
{**valid, 'items': valid['items'] * 2},
{**valid, 'items': [{'pipeline_uuid': 'one', 'preview_token': 'x', 'config': {}}]},
{**valid, 'items': [{'pipeline_uuid': str(i), 'preview_token': 'x'} for i in range(51)]},
]:
with pytest.raises(m.MigrationError):
m.validate_execute_request(body)
@pytest.mark.asyncio
async def test_preview_is_scoped_secret_free_and_read_only(env):
writes = []
def observe(_conn, _cursor, statement, *_args):
if statement.lstrip().upper().startswith(('INSERT', 'UPDATE', 'DELETE')):
writes.append(statement)
sa.event.listen(env.engine.sync_engine, 'before_cursor_execute', observe)
result = await env.svc.preview(context('viewer'))
assert result['total'] == 2
assert {i['pipeline_uuid'] for i in result['items']} == {'one', 'two'}
assert all(i['state'] == 'ready' and i['preview_token'] for i in result['items'])
assert 'synthetic-secret' not in str(result)
assert writes == []
env.ap.runner_registry.list_runners.assert_not_awaited()
env.ap.plugin_connector.require_workspace_context.assert_not_awaited()
env.ap.pipeline_mgr.prepare_pipeline.assert_not_awaited()
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
async def test_viewer_and_foreign_ids_rejected_before_runtime(env):
body = await selection(env)
with pytest.raises(PermissionDeniedError):
await env.svc.execute(context('viewer'), body)
body['items'].append({'pipeline_uuid': 'foreign', 'preview_token': 'x'})
with pytest.raises(env.m.MigrationError) as err:
await env.svc.execute(context(), body)
assert err.value.status_code == 404
assert not env.ap.task_mgr.tasks
env.ap.runner_registry.list_runners.assert_not_awaited()
@pytest.mark.asyncio
async def test_success_snapshot_atomic_preserves_source_and_task_scope(env):
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == planner(SOURCE)['config']
assert configs['two'] == SOURCE
assert len(backups) == 1 and backups[0]['source_snapshot']['config'] == SOURCE
assert backups[0]['state'] == 'active'
assert (task.instance_uuid, task.workspace_uuid, task.placement_generation) == ('instance', WS, 1)
assert task.task_context.metadata == {
'kind': 'pipeline_migration',
'results': [{'pipeline_uuid': 'one', 'state': 'migrated', 'code': None}],
}
assert 'synthetic-secret' not in str(task.to_dict())
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
@pytest.mark.asyncio
async def test_stale_preview_and_plugin_change_are_rejected(env):
body = await selection(env)
async with env.engine.begin() as conn:
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(config={'edit': True}))
with pytest.raises(env.m.MigrationError) as err:
await env.svc.execute(context(), body)
assert err.value.status_code == 409
body = await selection(env, ('two',))
async with env.engine.begin() as conn:
await conn.execute(sa.update(PluginSetting).values(enabled=False))
with pytest.raises(env.m.MigrationError):
await env.svc.execute(context(), body)
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
@pytest.mark.parametrize('failure', ['prepare', 'schema', 'membership', 'generation', 'edit'])
async def test_precommit_failures_keep_original_without_backup(env, failure):
body = await selection(env)
if failure == 'prepare':
env.ap.pipeline_mgr.prepare_pipeline.side_effect = RuntimeError('synthetic-secret')
elif failure == 'schema':
env.ap.runner_registry.list_runners.return_value[0].config_schema = []
async def prepare(*args, **kwargs):
assert env.pm.current_session() is None
if failure == 'membership':
env.access.membership.role = 'viewer'
if failure == 'generation':
env.ap.workspace_service.get_execution_binding.side_effect = RuntimeError('synthetic-secret')
if failure == 'edit':
async with env.engine.begin() as conn:
await conn.execute(
sa.update(LegacyPipeline)
.where(LegacyPipeline.uuid == 'one')
.values(extensions_preferences={'enable_all_plugins': False})
)
return 'candidate'
if failure not in ('prepare', 'schema'):
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env, body)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] in ('blocked', 'failed', 'stale')
assert 'synthetic-secret' not in str(task.to_dict())
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_activation_pending_explicit_retry_without_second_backup(env):
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('synthetic-secret')
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
configs, backups = await rows(env)
assert configs['one'] != SOURCE and len(backups) == 1
preview = await env.svc.preview(context())
assert preview['items'][0]['state'] == 'activation_pending'
env.ap.pipeline_mgr.publish_pipeline.side_effect = None
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
_, backups = await rows(env)
assert len(backups) == 1 and backups[0]['state'] == 'active'
@pytest.mark.asyncio
async def test_double_execute_commits_once_and_partial_results(env):
body = await selection(env, ('one', 'two'))
original_prepare = env.ap.pipeline_mgr.prepare_pipeline
async def prepare(_ctx, entity):
assert env.pm.current_session() is None
if entity['uuid'] == 'two':
raise RuntimeError('synthetic-secret')
await asyncio.sleep(0)
return 'candidate'
original_prepare.side_effect = prepare
first, second = await asyncio.gather(env.svc.execute(context(), body), env.svc.execute(context(), body))
await env.ap.task_mgr.wait_all()
configs, backups = await rows(env)
assert len(backups) == 1
assert configs['two'] == SOURCE
results = [env.ap.task_mgr.get_task_by_id(r['task_id']).task_context.metadata['results'] for r in (first, second)]
assert sum(item['state'] == 'migrated' for batch in results for item in batch) == 1
assert all(batch[1]['state'] == 'failed' for batch in results)
@pytest.mark.asyncio
async def test_ordinary_update_requires_manual_migration_but_allows_metadata(env):
from langbot.pkg.api.http.service.pipeline import PipelineService
service = PipelineService(env.ap)
env.ap.pipeline_mgr.remove_pipeline = AsyncMock()
env.ap.pipeline_mgr.load_pipeline = AsyncMock()
async with env.pm.tenant_scope(WS):
with pytest.raises(ValueError, match='manual_migration_required'):
await service.update_pipeline(context(), 'one', {'config': planner(SOURCE)['config']})
await service.update_pipeline(context(), 'one', {'name': 'renamed'})
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
@pytest.mark.asyncio
async def test_snapshot_commit_failure_rolls_back_config_and_backup(env, monkeypatch):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original = AsyncSessionTransaction.commit
async def fail_write_commit(transaction):
# Fail the actual transaction after both statements have run.
result = await transaction.session.execute(
sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot)
)
if result.scalar() > 0:
raise RuntimeError('synthetic-secret')
await original(transaction)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', fail_write_commit)
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
assert task.task_context.metadata['results'][0]['state'] == 'failed'
assert 'synthetic-secret' not in str(task.to_dict())
@pytest.mark.asyncio
async def test_plugin_changes_while_preparing_prevent_commit(env):
async def prepare(*args):
async with env.engine.begin() as conn:
await conn.execute(sa.update(PluginSetting).values(runtime_revision=2))
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] == 'stale'
@pytest.mark.asyncio
async def test_scope_token_cannot_be_reused_by_other_identity_or_generation(env):
import dataclasses
body = await selection(env)
ctx = dataclasses.replace(context(), placement_generation=2)
env.access.execution.placement_generation = 2
with pytest.raises(env.m.MigrationError):
await env.svc.execute(ctx, body)
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
async def test_conversation_invalidation_is_workspace_scoped(env):
good = NS(workspace_uuid=WS, using_conversation=NS(pipeline_uuid='one'))
foreign = NS(workspace_uuid=OTHER, using_conversation=NS(pipeline_uuid='one'))
env.ap.sess_mgr.session_list = [good, foreign]
await execute(env)
assert good.using_conversation is None
assert foreign.using_conversation is not None
@pytest.mark.asyncio
async def test_runtime_schema_changes_during_prepare_prevent_commit(env):
async def prepare(*args):
env.ap.runner_registry.list_runners.return_value[0].config_schema = []
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] == 'blocked'
@pytest.mark.asyncio
async def test_real_planner_deerflow_schema_integration(env, monkeypatch):
import json
from pathlib import Path
from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
source = {
'ai': {
'runner': {'runner': 'deerflow-api', 'expire-time': 60},
'deerflow-api': {'api-base': 'https://synthetic.invalid', 'api-key': 'synthetic-secret'},
},
'output': {'misc': {'remove-think': False}},
}
plan = plan_legacy_pipeline(source, {'enable_all_plugins': True})
assert plan['state'] == 'ready'
async with env.engine.begin() as conn:
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(config=source))
await conn.execute(
sa.insert(PluginSetting).values(
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='DeerFlowAgent', enabled=True
)
)
schema = json.loads((Path(__file__).parent / 'fixtures/pipeline_migration_deerflow_schema.json').read_text())
env.ap.runner_registry.list_runners.return_value = [
NS(
id=plan['target_runner_id'],
usages=['agent'],
plugin_version=plan['target_plugin']['version'],
config_schema=schema,
)
]
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
configs, backups = await rows(env)
assert configs['one'] == plan['config'] and backups[0]['source_snapshot']['config'] == source
@pytest.mark.asyncio
async def test_account_disabled_during_prepare_blocks_commit(env):
async def prepare(*args):
async with env.engine.begin() as conn:
await conn.execute(sa.update(User).where(User.uuid == 'account').values(status='disabled'))
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] == 'blocked'
@pytest.mark.asyncio
async def test_plugin_disabled_after_commit_defers_activation(env, monkeypatch):
original = env.svc._commit
async def commit(*args):
result = await original(*args)
async with env.engine.begin() as conn:
await conn.execute(sa.update(PluginSetting).values(enabled=False))
return result
monkeypatch.setattr(env.svc, '_commit', commit)
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_ambiguous_commit_acknowledgement_reports_pending(env, monkeypatch):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original = AsyncSessionTransaction.commit
failed = False
async def fail_ack(transaction):
nonlocal failed
count = (
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
).scalar()
await original(transaction)
if count and not failed:
failed = True
raise RuntimeError('synthetic-secret')
monkeypatch.setattr(AsyncSessionTransaction, 'commit', fail_ack)
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] != SOURCE and len(backups) == 1
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('durable', [False, True], ids=['before_commit', 'after_durable_commit'])
async def test_cancel_commit_reports_durable_outcome_and_stops_batch(env, monkeypatch, durable):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original = AsyncSessionTransaction.commit
cancelled = False
async def cancel_commit(transaction):
nonlocal cancelled
count = (
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
).scalar()
if count and not cancelled:
cancelled = True
if durable:
await original(transaction)
asyncio.current_task().cancel()
await asyncio.sleep(0)
await original(transaction)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', cancel_commit)
task = await execute(env, await selection(env, ('one', 'two')))
configs, backups = await rows(env)
assert cancelled
assert configs['one'] == (planner(SOURCE)['config'] if durable else SOURCE)
assert len(backups) == int(durable)
if durable:
assert backups[0]['state'] == 'activation_pending'
assert configs['two'] == SOURCE
assert task.task_context.metadata['results'] == [
{'pipeline_uuid': 'one', 'state': 'activation_pending' if durable else 'failed', 'code': 'operation_cancelled'},
{'pipeline_uuid': 'two', 'state': 'failed', 'code': 'operation_cancelled'},
]
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('durable', [False, True])
@pytest.mark.parametrize('initial_cancel', [False, True])
@pytest.mark.parametrize('reconcile_cancel', [False, True])
async def test_unavailable_commit_reconciliation_is_conservative(
env, monkeypatch, durable, initial_cancel, reconcile_cancel
):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original_commit = AsyncSessionTransaction.commit
original_execute = env.pm.execute_async
interrupted = False
reconciliation_attempts = 0
async def interrupt_commit(transaction):
nonlocal interrupted
count = (
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
).scalar()
if count and not interrupted:
interrupted = True
if durable:
await original_commit(transaction)
if initial_cancel:
asyncio.current_task().cancel()
await asyncio.sleep(0)
raise RuntimeError('synthetic-secret')
await original_commit(transaction)
async def unavailable_reconciliation(statement, *args, **kwargs):
nonlocal reconciliation_attempts
if interrupted:
reconciliation_attempts += 1
if reconcile_cancel:
# Repeated real cancellation must stop the batch, not start a
# shield/retry loop or leave the current result as pending.
asyncio.current_task().cancel()
asyncio.current_task().cancel()
await asyncio.sleep(0)
raise RuntimeError('synthetic-secret')
return await original_execute(statement, *args, **kwargs)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', interrupt_commit)
monkeypatch.setattr(env.pm, 'execute_async', unavailable_reconciliation)
task_id = (await env.svc.execute(context(), await selection(env, ('one', 'two'))))['task_id']
task = env.ap.task_mgr.get_task_by_id(task_id)
await asyncio.gather(task.task, return_exceptions=True)
assert task.task.cancelled() is reconcile_cancel
configs, backups = await rows(env)
assert configs['one'] == (planner(SOURCE)['config'] if durable else SOURCE)
assert len(backups) == int(durable)
assert task.task_context.metadata['results'][0] == {
'pipeline_uuid': 'one',
'state': 'activation_pending',
'code': 'commit_outcome_unknown',
}
if initial_cancel or reconcile_cancel:
assert reconciliation_attempts == 1
assert task.task_context.metadata['results'][1] == {
'pipeline_uuid': 'two',
'state': 'failed',
'code': 'operation_cancelled',
}
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
assert configs['two'] == SOURCE
assert 'synthetic-secret' not in str(task.task_context.metadata)
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('durable_retry_ack', [False, True])
@pytest.mark.parametrize('reconcile_failure', [None, 'error', 'cancel'])
async def test_cancel_activation_retry_reconciles_original_snapshot(
env, monkeypatch, durable_retry_ack, reconcile_failure
):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('synthetic-secret')
await execute(env)
before_configs, before_backups = await rows(env)
body = await selection(env, ('one', 'two'))
env.ap.pipeline_mgr.publish_pipeline.reset_mock(side_effect=True)
env.ap.pipeline_mgr.prepare_pipeline.reset_mock()
original_commit = AsyncSessionTransaction.commit
original_service_commit = env.svc._commit
original_execute = env.pm.execute_async
interrupted = False
in_commit = False
reconciliation_attempts = 0
async def service_commit(*args):
nonlocal in_commit
in_commit = True
try:
return await original_service_commit(*args)
finally:
in_commit = False
async def cancel_ack(transaction):
nonlocal interrupted
if in_commit and not interrupted:
interrupted = True
if durable_retry_ack:
await original_commit(transaction)
asyncio.current_task().cancel()
await asyncio.sleep(0)
await original_commit(transaction)
async def reconcile(statement, *args, **kwargs):
nonlocal reconciliation_attempts
if interrupted and not in_commit:
reconciliation_attempts += 1
if reconcile_failure == 'cancel':
asyncio.current_task().cancel()
await asyncio.sleep(0)
if reconcile_failure == 'error':
raise RuntimeError('synthetic-secret')
return await original_execute(statement, *args, **kwargs)
monkeypatch.setattr(env.svc, '_commit', service_commit)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', cancel_ack)
monkeypatch.setattr(env.pm, 'execute_async', reconcile)
task_id = (await env.svc.execute(context(), body))['task_id']
task = env.ap.task_mgr.get_task_by_id(task_id)
await asyncio.gather(task.task, return_exceptions=True)
configs, backups = await rows(env)
assert interrupted and configs == before_configs
assert backups == before_backups
assert backups[0]['state'] == 'activation_pending'
assert reconciliation_attempts == 1
assert task.task.cancelled() is (reconcile_failure == 'cancel')
assert task.task_context.metadata['results'] == [
{
'pipeline_uuid': 'one',
'state': 'activation_pending',
'code': 'commit_outcome_unknown' if reconcile_failure else 'operation_cancelled',
},
{'pipeline_uuid': 'two', 'state': 'failed', 'code': 'operation_cancelled'},
]
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
assert 'synthetic-secret' not in str(task.task_context.metadata)
@pytest.mark.asyncio
async def test_cancel_during_prepare_keeps_original_and_stops_batch(env):
async def cancel_prepare(*args):
asyncio.current_task().cancel()
await asyncio.sleep(0)
env.ap.pipeline_mgr.prepare_pipeline.side_effect = cancel_prepare
task = await execute(env, await selection(env, ('one', 'two')))
configs, backups = await rows(env)
assert configs['one'] == configs['two'] == SOURCE and not backups
assert all(
r['state'] == 'failed' and r['code'] == 'operation_cancelled' for r in task.task_context.metadata['results']
)
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@@ -0,0 +1,119 @@
"""Actual planner + artifact descriptors + migration service + SQLite snapshots.
Vendor calls and runtime publication are fixtures. Database writes, snapshots,
scoped service validation, and converter output are real. Browser tests consume
this same synthetic contract, without requiring Python or a sibling checkout.
"""
import copy
import json
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy as sa
import yaml
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider
from langbot.pkg.pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline
from tests.unit_tests.api.service import test_pipeline_migration as base
ROOT = Path(__file__).parents[4]
CONTRACT = json.loads((ROOT / 'web/tests/e2e/fixtures/runner-migration-contract.json').read_text())
env = base.env
@pytest.mark.parametrize('case', CONTRACT['cases'], ids=lambda case: case['legacy_runner'])
def test_portable_browser_contract_matches_actual_converter(case):
assert CONTRACT['planner_version'] == PLANNER_VERSION
assert plan_legacy_pipeline(case['source']) == case['plan']
assert len(CONTRACT['cases']) == len(CONTRACT['plugins']) == 9
for name in ('ai', 'output'):
schema = yaml.safe_load((ROOT / f'src/langbot/templates/metadata/pipeline/{name}.yaml').read_text())
assert CONTRACT[f'{name}_schema'] == schema
@pytest.mark.asyncio
@pytest.mark.parametrize('source_mode', ['local', 'cloud_projection'])
@pytest.mark.parametrize('case', CONTRACT['cases'], ids=lambda case: case['legacy_runner'])
async def test_all_runner_service_roundtrip_retains_complete_backup(env, monkeypatch, case, source_mode):
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
source = copy.deepcopy(case['source'])
plan = plan_legacy_pipeline(source)
target = plan['target_plugin']
artifact = next(
item for item in CONTRACT['plugins'].values() if item['manifest']['metadata']['name'] == target['name']
)
schema = artifact['component']['spec']
env.ap.runner_registry.list_runners.return_value = [
RunnerDescriptor(
id=plan['target_runner_id'],
source='plugin',
label={},
plugin_author=target['author'],
plugin_name=target['name'],
plugin_version=target['version'],
runner_name='default',
usages=schema['usages'],
config_schema=schema['config'],
capabilities=schema.get('capabilities', {}),
permissions=schema.get('permissions', {}),
)
]
env.ap.logger = Mock()
env.ap.tool_mgr = NS(
get_resolved_tool_catalog=AsyncMock(return_value=[]), get_tool_schema=AsyncMock(return_value=('', {}))
)
async with env.engine.begin() as conn:
await conn.run_sync(
lambda c: ModelProvider.metadata.create_all(c, tables=[ModelProvider.__table__, LLMModel.__table__])
)
await conn.execute(
sa.insert(ModelProvider).values(
uuid='fixture-provider',
workspace_uuid=base.WS,
name='fixture',
requester='test',
base_url='https://synthetic.invalid',
)
)
await conn.execute(
sa.insert(LLMModel).values(
uuid='synthetic-model', workspace_uuid=base.WS, name='fixture', provider_uuid='fixture-provider'
)
)
await conn.execute(sa.update(base.Workspace).where(base.Workspace.uuid == base.WS).values(source=source_mode))
await conn.execute(
sa.update(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'one').values(config=source)
)
await conn.execute(
sa.insert(base.PluginSetting).values(
workspace_uuid=base.WS,
plugin_author=target['author'],
plugin_name=target['name'],
enabled=True,
install_info={'version': target['version']},
)
)
before, snapshots = await base.rows(env)
assert not snapshots
preview = await env.svc.preview(base.context())
selected = next(row for row in preview['items'] if row['pipeline_uuid'] == 'one')
assert selected['state'] == 'ready', selected['blockers']
assert selected['preview_token']
assert await base.rows(env) == (before, snapshots)
task = await base.execute(
env, {'confirmed': True, 'items': [{key: selected[key] for key in ('pipeline_uuid', 'preview_token')}]}
)
assert task.task_context.metadata['results'] == [{'pipeline_uuid': 'one', 'state': 'migrated', 'code': None}]
configs, backups = await base.rows(env)
assert configs['one'] == plan['config']
assert set(configs['one']['ai']) == {'runner', 'runner_config'}
assert configs['two'] == before['two'] and configs['foreign'] == before['foreign']
assert len(backups) == 1
assert backups[0]['source_snapshot']['config'] == source
assert set(backups[0]['source_snapshot']['config']['ai']) == {'runner', *CONTRACT['legacy_sections']}
assert backups[0]['state'] == 'active'
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
@@ -0,0 +1,121 @@
"""Shared admission and real embedded artifact schema regressions."""
import copy
import json
from pathlib import Path
from types import SimpleNamespace as NS
import pytest
import sqlalchemy as sa
from tests.unit_tests.api.service.test_pipeline_migration import (
env as migration_env,
execute,
WS,
SOURCE,
RID,
planner,
)
from langbot.pkg.agent.runner.interaction_store import InteractionStore, InteractionScopeError
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
@pytest.fixture
async def env(tmp_path, monkeypatch):
async for value in migration_env.__wrapped__(tmp_path, monkeypatch):
yield value
async def request(env, **kwargs):
return await InteractionStore(env.pm.get_db_engine()).create_request(
interaction_id='form',
run_id='run',
binding_id='binding',
runner_id=RID,
processor_type='pipeline',
processor_id='one',
workspace_id=WS,
request={},
delivery_target={},
expected_config=copy.deepcopy(SOURCE),
authority_check=lambda: True,
**kwargs,
)
@pytest.mark.asyncio
async def test_stale_writer_after_commit_rejected(env):
original = env.svc._activate
async def activate(*args):
with pytest.raises(InteractionScopeError):
await request(env)
return await original(*args)
env.svc._activate = activate
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
with pytest.raises(InteractionScopeError):
await request(env)
async with env.engine.connect() as conn:
assert not (await conn.execute(sa.select(AgentInteraction))).all()
@pytest.mark.asyncio
async def test_writer_requires_pipeline_authority(env):
with pytest.raises(InteractionScopeError):
await InteractionStore(env.engine).create_request(
interaction_id='form',
run_id='run',
binding_id='binding',
runner_id=RID,
processor_type='pipeline',
processor_id='one',
workspace_id=WS,
request={},
delivery_target={},
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'folder,field,good,bad',
[
('LangflowAgent', 'tweaks', {'node': {'value': 1}}, ['not-an-object']),
('WeKnoraAgent', 'knowledge-base-ids', ['kb-a'], [1]),
],
)
async def test_embedded_artifact_schema_types(env, folder, field, good, bad):
root = Path(__file__).parent / 'fixtures'
artifact = 'langflow-agent' if folder == 'LangflowAgent' else 'weknora-agent'
spec = {'config': json.loads((root / (artifact + '-artifact-schema.json')).read_text())}
kind = 'json' if folder == 'LangflowAgent' else 'array[string]'
schema = next(s for s in spec['config'] if s['type'] == kind)
descriptor = env.ap.runner_registry.list_runners.return_value[0]
descriptor.config_schema = [schema]
plan = planner(SOURCE)
plan['config']['ai']['runner_config'][RID] = {schema['name']: good}
await env.svc._verify_runtime(NS(workspace_uuid=WS), plan)
plan['config']['ai']['runner_config'][RID][schema['name']] = bad
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
await env.svc._verify_runtime(NS(workspace_uuid=WS), plan)
def test_conversation_authority_is_captured_before_runner_returns():
from langbot.pkg.agent.runner.interaction_manager import InteractionManager
old, new = NS(), NS()
config = planner(SOURCE)['config']
query = NS(pipeline_config=config, pipeline_uuid='one', session=NS(using_conversation=new))
binding = NS(
processor_type='pipeline', processor_id='one', runner_id=RID, runner_config=config['ai']['runner_config'][RID]
)
authority = InteractionManager._pipeline_admission(
binding,
NS(id=RID),
{
'_query': query,
'_pipeline_expected_config': copy.deepcopy(config),
'_pipeline_conversation': old,
},
)
assert authority['authority_check']() is False
@@ -0,0 +1,234 @@
"""Migration must not abandon native or durable human-input requests."""
import copy
import sys
from types import SimpleNamespace as NS
import pytest
import sqlalchemy as sa
from tests.unit_tests.api.service import test_pipeline_migration as existing
from tests.unit_tests.api.service.test_pipeline_migration import (
context,
selection,
execute,
rows,
WS,
OTHER,
SOURCE,
)
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
env = existing.env
NATIVE = 'langbot.pkg.provider.runners.difysvapi'
@pytest.fixture(autouse=True)
async def interaction_schema(env):
async with env.engine.begin() as conn:
await conn.run_sync(lambda c: AgentInteraction.__table__.create(c, checkfirst=True))
async def add_request(env, status='pending', workspace=WS, pipeline='one'):
async with env.engine.begin() as conn:
await conn.execute(
sa.insert(AgentInteraction).values(
interaction_id='form',
run_id=f'{workspace}-{pipeline}-{status}',
binding_id='binding',
runner_id='plugin:langbot-team/DifyAgent/default',
processor_type='pipeline',
processor_id=pipeline,
workspace_id=workspace,
status=status,
request_json='{"secret":"private-form"}',
callback_token_hash=f'{workspace}-{pipeline}-{status}',
)
)
def native(monkeypatch, workspace=WS, pipeline='one', instance='instance'):
forms = {
(instance, workspace, 1, 'bot', pipeline, 'adapter', 'person', 'actor'): {
'private-token': {'inputs': {'secret': 'private-form'}}
}
}
monkeypatch.setitem(sys.modules, NATIVE, NS(_PENDING_FORMS=forms))
return forms
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['native', 'durable'])
async def test_pending_preview_readonly_secret_free(env, monkeypatch, source):
cache = native(monkeypatch) if source == 'native' else None
if source == 'durable':
await add_request(env)
before = copy.deepcopy(cache)
writes = []
def observe(_conn, _cursor, statement, *_args):
if statement.lstrip().upper().startswith(('INSERT', 'UPDATE', 'DELETE')):
writes.append(statement)
sa.event.listen(env.engine.sync_engine, 'before_cursor_execute', observe)
result = await env.svc.preview(context())
item = result['items'][0]
assert item['state'] == 'blocked'
assert {'code': 'runtime.pending_interaction'} in item['blockers']
assert item['preview_token'] is None
assert result['items'][1]['state'] == 'ready'
assert 'private-' not in str(result)
assert cache == before and not writes
env.ap.pipeline_mgr.prepare_pipeline.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('status', ['submitted', 'cancelled', 'expired', 'delivery_failed'])
async def test_terminal_requests_allow_migration(env, status):
await add_request(env, status)
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['native', 'durable'])
async def test_other_scope_does_not_block(env, monkeypatch, source):
if source == 'native':
native(monkeypatch, workspace=OTHER)
else:
await add_request(env, workspace=OTHER)
await add_request(env, pipeline='two')
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['native', 'durable'])
async def test_request_after_preview_stales_token(env, monkeypatch, source):
body = await selection(env)
if source == 'native':
native(monkeypatch)
else:
await add_request(env)
with pytest.raises(env.m.MigrationError, match='preview_stale'):
await env.svc.execute(context(), body)
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['native', 'durable'])
async def test_new_request_during_prepare_prevents_snapshot_and_reset(env, monkeypatch, source):
conversation = NS(pipeline_uuid='one')
session = NS(workspace_uuid=WS, instance_uuid='instance', using_conversation=conversation)
env.ap.sess_mgr.session_list = [session]
async def prepare(*args):
if source == 'native':
native(monkeypatch)
else:
await add_request(env)
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, snapshots = await rows(env)
assert configs['one'] == SOURCE and not snapshots
assert session.using_conversation is conversation
assert task.task_context.metadata['results'][0]['state'] == 'stale'
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_activation_boundary_catches_new_request(env, monkeypatch):
original = env.svc._commit
async def commit(*args):
result = await original(*args)
await add_request(env)
return result
monkeypatch.setattr(env.svc, '_commit', commit)
task = await execute(env)
assert task.task_context.metadata['results'][0] == {
'pipeline_uuid': 'one',
'state': 'activation_pending',
'code': 'runtime.pending_interaction',
}
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
_, snapshots = await rows(env)
assert snapshots[0]['state'] == 'activation_pending'
@pytest.mark.asyncio
async def test_activation_retry_cannot_erase_pending_form(env):
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('activation failed')
await execute(env)
body = await selection(env)
before = await rows(env)
await add_request(env)
with pytest.raises(env.m.MigrationError, match='preview_stale'):
await env.svc.execute(context(), body)
assert await rows(env) == before
assert (await env.svc.preview(context()))['items'][0]['state'] == 'blocked'
@pytest.mark.asyncio
async def test_missing_interaction_table_fails_closed(env):
async with env.engine.begin() as conn:
await conn.run_sync(lambda c: AgentInteraction.__table__.drop(c))
result = await env.svc.preview(context())
assert result['items'][0]['state'] == 'blocked'
assert {'code': 'runtime.pending_interaction'} in result['items'][0]['blockers']
@pytest.mark.asyncio
async def test_unknown_native_scope_fails_closed_without_payload(env, monkeypatch):
native(monkeypatch, workspace='')
result = await env.svc.preview(context())
assert result['items'][0]['state'] == 'blocked'
assert 'private-' not in str(result)
@pytest.mark.asyncio
async def test_terminal_state_change_requires_fresh_preview(env):
await add_request(env, 'submitted')
body = await selection(env)
async with env.engine.begin() as conn:
await conn.execute(sa.update(AgentInteraction).values(status='cancelled'))
with pytest.raises(env.m.MigrationError, match='preview_stale'):
await env.svc.execute(context(), body)
@pytest.mark.asyncio
async def test_unsupported_atomic_boundary_not_advertised_ready(env, monkeypatch):
from unittest.mock import AsyncMock
monkeypatch.setattr(env.pm, 'get_db_engine', lambda: NS(dialect=NS(name='unsupported')))
monkeypatch.setattr(env.pm, 'execute_async', AsyncMock(return_value=NS(all=lambda: [])))
_, blocked = await env.svc._interaction_state(context(), 'one')
assert blocked
@pytest.mark.asyncio
async def test_native_form_arriving_during_cas_rolls_back(env, monkeypatch):
original = env.pm.execute_async
async def execute_sql(statement, *args, **kwargs):
result = await original(statement, *args, **kwargs)
if isinstance(statement, sa.sql.dml.Insert) and statement.table.name == 'pipeline_migration_snapshots':
native(monkeypatch)
return result
monkeypatch.setattr(env.pm, 'execute_async', execute_sql)
await execute(env)
configs, snapshots = await rows(env)
assert configs['one'] == SOURCE and not snapshots
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
# Also exercise the unchanged regression assertions with this module's
# interaction-schema fixture. The original tests remain in their own module.
for _name, _test in vars(existing).items():
if _name.startswith('test_') and callable(_test):
globals()['test_existing_' + _name[5:]] = _test
@@ -0,0 +1,170 @@
"""Real scoped database checks against the reviewed LocalAgent descriptor."""
import copy
import json
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy as sa
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel
from langbot.pkg.entity.persistence.rag import KnowledgeBase
from langbot.pkg.entity.persistence.mcp import MCPServer
from tests.unit_tests.api.service import test_pipeline_migration as base
from tests.unit_tests.api.service.test_pipeline_migration import context, WS, OTHER
env = base.env
@pytest.fixture
async def local(env):
spec = json.loads((Path(__file__).parent / 'fixtures/pipeline_migration_local_schema.json').read_text())
rid = 'plugin:langbot-team/LocalAgent/default'
descriptor = RunnerDescriptor(
id=rid,
source='plugin',
label={},
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
plugin_version='reviewed',
usages=['agent'],
config_schema=spec['config'],
capabilities=spec['capabilities'],
permissions=spec['permissions'],
)
env.ap.runner_registry.list_runners.return_value = [descriptor]
env.ap.logger = Mock()
env.ap.tool_mgr = NS(
get_resolved_tool_catalog=AsyncMock(
return_value=[{'name': 'scoped_tool', 'source': 'mcp', 'source_id': 'mcp'}]
),
get_tool_schema=AsyncMock(return_value=('', {})),
)
async with env.engine.begin() as conn:
await conn.run_sync(
lambda c: ModelProvider.metadata.create_all(
c, tables=[ModelProvider.__table__, LLMModel.__table__, KnowledgeBase.__table__, MCPServer.__table__]
)
)
for ws, suffix in [(WS, ''), (OTHER, '_foreign')]:
await conn.execute(
sa.insert(ModelProvider).values(
uuid='provider' + suffix,
workspace_uuid=ws,
name='provider',
requester='test',
base_url='https://synthetic.invalid',
)
)
await conn.execute(
sa.insert(LLMModel).values(
uuid='model' + suffix, workspace_uuid=ws, name='model', provider_uuid='provider' + suffix
)
)
await conn.execute(sa.insert(KnowledgeBase).values(uuid='kb' + suffix, workspace_uuid=ws, name='kb'))
await conn.execute(
sa.insert(MCPServer).values(
uuid='mcp' + suffix, workspace_uuid=ws, name='mcp' + suffix, mode='remote', enable=True
)
)
config = {item['name']: copy.deepcopy(item['default']) for item in spec['config'] if 'default' in item}
config.update(
{
'model': {'primary': 'model', 'fallbacks': [], 'reasoning': {}},
'knowledge-bases': ['kb'],
'tools': ['scoped_tool'],
'enable-all-tools': False,
'mcp-resources': [{'server_uuid': 'mcp', 'uri': 'test://document', 'enabled': True}],
'mcp-resource-agent-read-enabled': True,
}
)
plan = {
'target_runner_id': rid,
'target_plugin': {'version': 'reviewed'},
'config': {'ai': {'runner': {'id': rid}, 'runner_config': {rid: config}}},
}
return env, descriptor, config, plan
@pytest.mark.asyncio
async def test_valid_local_resources_and_real_descriptor_options(local):
env, _, _, plan = local
async with env.pm.tenant_scope(WS):
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
env.ap.tool_mgr.get_resolved_tool_catalog.assert_awaited_once()
ctx = env.ap.tool_mgr.get_resolved_tool_catalog.call_args.args[0]
assert ctx.workspace_uuid == WS
assert env.pm.current_session() is None
@pytest.mark.asyncio
@pytest.mark.parametrize('resource', ['model', 'kb', 'mcp', 'tool'])
async def test_foreign_or_unresolved_resource_is_denied(local, resource):
env, _, config, plan = local
if resource == 'model':
config['model']['primary'] = 'model_foreign'
elif resource == 'kb':
config['knowledge-bases'] = ['kb_foreign']
elif resource == 'mcp':
config['mcp-resources'][0]['server_uuid'] = 'mcp_foreign'
else:
config['tools'] = ['foreign_tool']
async with env.pm.tenant_scope(WS):
with pytest.raises(env.m.MigrationError, match='runner_resource_unavailable'):
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
@pytest.mark.asyncio
@pytest.mark.parametrize('declared', [False, True])
async def test_null_requires_explicit_descriptor_nullable(local, declared):
env, descriptor, config, plan = local
config['timeout'] = None
field = next(f for f in descriptor.config_schema if f['name'] == 'timeout')
field['nullable'] = declared
async with env.pm.tenant_scope(WS):
if declared:
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
else:
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
@pytest.mark.asyncio
async def test_old_plugin_version_is_rejected_before_resource_resolution(local):
env, descriptor, _, plan = local
descriptor.plugin_version = 'old'
async with env.pm.tenant_scope(WS):
with pytest.raises(env.m.MigrationError, match='plugin_version_incompatible'):
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
env.ap.tool_mgr.get_resolved_tool_catalog.assert_not_awaited()
@pytest.mark.asyncio
async def test_local_descriptor_migrates_through_detached_task(local, monkeypatch):
env, _, _, plan = local
def planner(config, extensions_preferences=None):
result = base.planner(config, extensions_preferences)
if result['state'] == 'ready':
result.update(copy.deepcopy(plan))
result['target_plugin'].update(author='langbot-team', name='LocalAgent')
return result
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', planner)
async with env.engine.begin() as conn:
await conn.execute(
sa.insert(base.PluginSetting).values(
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='LocalAgent', enabled=True
)
)
task = await base.execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
configs, snapshots = await base.rows(env)
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()
@@ -0,0 +1,60 @@
import importlib
import importlib.util
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock
import pytest
import quart
from langbot.pkg.api.http.context import RequestContext, WorkspaceContext, PrincipalContext, PrincipalType
from langbot.pkg.api.http.authz import permissions_for_role
@pytest.mark.asyncio
async def test_manual_routes_user_token_permissions_and_strict_request():
path = 'langbot.pkg.api.http.controller.groups.pipelines.migration'
assert importlib.util.find_spec(path), 'manual migration routes missing'
cls = importlib.import_module(path).PipelineMigrationRouterGroup
from langbot.pkg.api.http.service.pipeline_migration import MigrationError
app = quart.Quart(__name__)
router = cls(NS(persistence_mgr=NS()), app)
router._authenticate_support_admin = AsyncMock(return_value=None)
router._authenticate_account = AsyncMock(return_value=(NS(uuid='account'), 'test@example.invalid'))
viewer = RequestContext(
'instance',
1,
'request',
'user_token',
PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account'),
WorkspaceContext('workspace', 'membership', 'viewer', permissions_for_role('viewer')),
)
router._resolve_account_context = AsyncMock(return_value=viewer)
await router.initialize()
router.service = NS(
preview=AsyncMock(return_value={'items': [], 'total': 0}), execute=AsyncMock(return_value={'task_id': 1})
)
client = app.test_client()
base = '/api/v1/pipelines/_/migration'
assert (await client.get(base + '/preview')).status_code == 401
assert (await client.get(base + '/preview', headers={'X-API-Key': 'synthetic'})).status_code == 401
headers = {'Authorization': 'Bearer synthetic'}
assert (await client.get(base + '/preview', headers=headers)).status_code == 200
assert (await client.post(base + '/execute', headers=headers, json={})).status_code == 403
router.service.execute.assert_not_awaited()
manager = RequestContext(
'instance',
1,
'request',
'user_token',
viewer.principal,
WorkspaceContext('workspace', 'membership', 'developer', permissions_for_role('developer')),
)
router._resolve_account_context.return_value = manager
router.service.execute.side_effect = MigrationError('confirmation_required', 400)
response = await client.post(base + '/execute', headers=headers, json={'confirmed': 'true'})
assert response.status_code == 400
assert 'confirmation_required' in await response.get_data(as_text=True)
router.service.execute.side_effect = None
response = await client.post(base + '/execute', headers=headers, json={'confirmed': True, 'items': []})
assert (await response.get_json())['data']['task_id'] == 1