mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-01 13:56:53 +08:00
feat: add verified manual pipeline migration for plugin runners
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user