mirror of
https://github.com/langgenius/dify.git
synced 2026-04-27 05:58:14 +08:00
Do not allow create WorkflowTool from workflows containing HumanInput nodes (vibe-kanban 9e27ac53)
The HumanInput node will pause the execution of workflow if execution, while WorkflowTool works in a blocking manner. (Receiving arguments and returning results once finished). The two mode are inherently incompatible. The goal of this change is to forbid workflows containing HumanInput node being published as WorkflowTool. Please check the current implementation and propose a solution.
This commit is contained in:
@ -1,3 +1,5 @@
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
from core.tools.entities.tool_entities import ToolInvokeMeta
|
||||
|
||||
|
||||
@ -37,6 +39,12 @@ class ToolCredentialPolicyViolationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowToolHumanInputNotSupportedError(BaseHTTPException):
|
||||
error_code = "workflow_tool_human_input_not_supported"
|
||||
description = "Workflow with Human Input nodes cannot be published as a workflow tool."
|
||||
code = 400
|
||||
|
||||
|
||||
class ToolEngineInvokeError(Exception):
|
||||
meta: ToolInvokeMeta
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@ from typing import Any
|
||||
|
||||
from core.app.app_config.entities import VariableEntity
|
||||
from core.tools.entities.tool_entities import WorkflowToolParameterConfiguration
|
||||
from core.tools.errors import WorkflowToolHumanInputNotSupportedError
|
||||
from core.workflow.enums import NodeType
|
||||
from core.workflow.nodes.base.entities import OutputVariableEntity
|
||||
|
||||
|
||||
@ -50,6 +52,13 @@ class WorkflowToolConfigurationUtils:
|
||||
|
||||
return [outputs_by_variable[variable] for variable in variable_order]
|
||||
|
||||
@classmethod
|
||||
def ensure_no_human_input_nodes(cls, graph: Mapping[str, Any]) -> None:
|
||||
nodes = graph.get("nodes", [])
|
||||
for node in nodes:
|
||||
if node.get("data", {}).get("type") == NodeType.HUMAN_INPUT:
|
||||
raise WorkflowToolHumanInputNotSupportedError()
|
||||
|
||||
@classmethod
|
||||
def check_is_synced(
|
||||
cls, variables: list[VariableEntity], tool_configurations: list[WorkflowToolParameterConfiguration]
|
||||
|
||||
@ -68,6 +68,8 @@ class WorkflowToolManageService:
|
||||
if workflow is None:
|
||||
raise ValueError(f"Workflow not found for app {workflow_app_id}")
|
||||
|
||||
WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(workflow.graph_dict)
|
||||
|
||||
workflow_tool_provider = WorkflowToolProvider(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@ -163,6 +165,8 @@ class WorkflowToolManageService:
|
||||
if workflow is None:
|
||||
raise ValueError(f"Workflow not found for app {workflow_tool_provider.app_id}")
|
||||
|
||||
WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(workflow.graph_dict)
|
||||
|
||||
workflow_tool_provider.name = name
|
||||
workflow_tool_provider.label = label
|
||||
workflow_tool_provider.icon = json.dumps(icon)
|
||||
|
||||
@ -4,6 +4,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from faker import Faker
|
||||
|
||||
from core.tools.errors import WorkflowToolHumanInputNotSupportedError
|
||||
from models.tools import WorkflowToolProvider
|
||||
from models.workflow import Workflow as WorkflowModel
|
||||
from services.account_service import AccountService, TenantService
|
||||
@ -257,7 +258,7 @@ class TestWorkflowToolManageService:
|
||||
|
||||
# Attempt to create second workflow tool with same name
|
||||
second_tool_parameters = self._create_test_workflow_tool_parameters()
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
with pytest.raises(WorkflowToolHumanInputNotSupportedError) as exc_info:
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id=account.id,
|
||||
tenant_id=account.current_tenant.id,
|
||||
@ -507,6 +508,62 @@ class TestWorkflowToolManageService:
|
||||
|
||||
assert tool_count == 0
|
||||
|
||||
def test_create_workflow_tool_human_input_node_error(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test workflow tool creation fails when workflow contains human input nodes.
|
||||
|
||||
This test verifies:
|
||||
- Human input nodes prevent workflow tool publishing
|
||||
- Correct error message
|
||||
- No database changes when workflow is invalid
|
||||
"""
|
||||
fake = Faker()
|
||||
|
||||
# Create test data
|
||||
app, account, workflow = self._create_test_app_and_account(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
workflow.graph = json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "human_input_node",
|
||||
"data": {"type": "human-input"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
tool_parameters = self._create_test_workflow_tool_parameters()
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id=account.id,
|
||||
tenant_id=account.current_tenant.id,
|
||||
workflow_app_id=app.id,
|
||||
name=fake.word(),
|
||||
label=fake.word(),
|
||||
icon={"type": "emoji", "emoji": "🔧"},
|
||||
description=fake.text(max_nb_chars=200),
|
||||
parameters=tool_parameters,
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == "workflow_tool_human_input_not_supported"
|
||||
|
||||
from extensions.ext_database import db
|
||||
|
||||
tool_count = (
|
||||
db.session.query(WorkflowToolProvider)
|
||||
.where(
|
||||
WorkflowToolProvider.tenant_id == account.current_tenant.id,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
assert tool_count == 0
|
||||
|
||||
def test_update_workflow_tool_success(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
"""
|
||||
Test successful workflow tool update with valid parameters.
|
||||
@ -593,6 +650,80 @@ class TestWorkflowToolManageService:
|
||||
mock_external_service_dependencies["tool_label_manager"].update_tool_labels.assert_called()
|
||||
mock_external_service_dependencies["tool_transform_service"].workflow_provider_to_controller.assert_called()
|
||||
|
||||
def test_update_workflow_tool_human_input_node_error(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test workflow tool update fails when workflow contains human input nodes.
|
||||
|
||||
This test verifies:
|
||||
- Human input nodes prevent workflow tool updates
|
||||
- Correct error message
|
||||
- Existing tool data remains unchanged
|
||||
"""
|
||||
fake = Faker()
|
||||
|
||||
# Create test data
|
||||
app, account, workflow = self._create_test_app_and_account(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
# Create initial workflow tool
|
||||
initial_tool_name = fake.word()
|
||||
initial_tool_parameters = self._create_test_workflow_tool_parameters()
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id=account.id,
|
||||
tenant_id=account.current_tenant.id,
|
||||
workflow_app_id=app.id,
|
||||
name=initial_tool_name,
|
||||
label=fake.word(),
|
||||
icon={"type": "emoji", "emoji": "🔧"},
|
||||
description=fake.text(max_nb_chars=200),
|
||||
parameters=initial_tool_parameters,
|
||||
)
|
||||
|
||||
from extensions.ext_database import db
|
||||
|
||||
created_tool = (
|
||||
db.session.query(WorkflowToolProvider)
|
||||
.where(
|
||||
WorkflowToolProvider.tenant_id == account.current_tenant.id,
|
||||
WorkflowToolProvider.app_id == app.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
original_name = created_tool.name
|
||||
|
||||
workflow.graph = json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "human_input_node",
|
||||
"data": {"type": "human-input"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
with pytest.raises(WorkflowToolHumanInputNotSupportedError) as exc_info:
|
||||
WorkflowToolManageService.update_workflow_tool(
|
||||
user_id=account.id,
|
||||
tenant_id=account.current_tenant.id,
|
||||
workflow_tool_id=created_tool.id,
|
||||
name=fake.word(),
|
||||
label=fake.word(),
|
||||
icon={"type": "emoji", "emoji": "⚙️"},
|
||||
description=fake.text(max_nb_chars=200),
|
||||
parameters=initial_tool_parameters,
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == "workflow_tool_human_input_not_supported"
|
||||
|
||||
db.session.refresh(created_tool)
|
||||
assert created_tool.name == original_name
|
||||
|
||||
def test_update_workflow_tool_not_found_error(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
"""
|
||||
Test workflow tool update fails when tool does not exist.
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
|
||||
from core.tools.errors import WorkflowToolHumanInputNotSupportedError
|
||||
from core.tools.utils.workflow_configuration_sync import WorkflowToolConfigurationUtils
|
||||
|
||||
|
||||
def test_ensure_no_human_input_nodes_passes_for_non_human_input():
|
||||
graph = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start_node",
|
||||
"data": {"type": "start"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(graph)
|
||||
|
||||
|
||||
def test_ensure_no_human_input_nodes_raises_for_human_input():
|
||||
graph = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "human_input_node",
|
||||
"data": {"type": "human-input"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(WorkflowToolHumanInputNotSupportedError) as exc_info:
|
||||
WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(graph)
|
||||
|
||||
assert exc_info.value.error_code == "workflow_tool_human_input_not_supported"
|
||||
@ -0,0 +1,154 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tools.errors import WorkflowToolHumanInputNotSupportedError
|
||||
from models.model import App
|
||||
from models.tools import WorkflowToolProvider
|
||||
from services.tools import workflow_tools_manage_service
|
||||
|
||||
|
||||
class DummyWorkflow:
|
||||
def __init__(self, graph_dict: dict, version: str = "1.0.0") -> None:
|
||||
self._graph_dict = graph_dict
|
||||
self.version = version
|
||||
|
||||
@property
|
||||
def graph_dict(self) -> dict:
|
||||
return self._graph_dict
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, result):
|
||||
self._result = result
|
||||
|
||||
def where(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._result
|
||||
|
||||
|
||||
class DummySession:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[object] = []
|
||||
|
||||
def add(self, obj) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
def begin(self):
|
||||
return DummyBegin(self)
|
||||
|
||||
|
||||
class DummyBegin:
|
||||
def __init__(self, session: DummySession) -> None:
|
||||
self._session = session
|
||||
|
||||
def __enter__(self) -> DummySession:
|
||||
return self._session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class DummySessionContext:
|
||||
def __init__(self, session: DummySession) -> None:
|
||||
self._session = session
|
||||
|
||||
def __enter__(self) -> DummySession:
|
||||
return self._session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class DummySessionFactory:
|
||||
def __init__(self, session: DummySession) -> None:
|
||||
self._session = session
|
||||
|
||||
def create_session(self) -> DummySessionContext:
|
||||
return DummySessionContext(self._session)
|
||||
|
||||
|
||||
def _build_fake_session(app) -> SimpleNamespace:
|
||||
def query(model):
|
||||
if model is WorkflowToolProvider:
|
||||
return FakeQuery(None)
|
||||
if model is App:
|
||||
return FakeQuery(app)
|
||||
return FakeQuery(None)
|
||||
|
||||
return SimpleNamespace(query=query)
|
||||
|
||||
|
||||
def test_create_workflow_tool_rejects_human_input_nodes(monkeypatch):
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": [{"id": "node_1", "data": {"type": "human-input"}}]})
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
|
||||
fake_session = _build_fake_session(app)
|
||||
monkeypatch.setattr(workflow_tools_manage_service.db, "session", fake_session)
|
||||
|
||||
mock_from_db = MagicMock()
|
||||
monkeypatch.setattr(workflow_tools_manage_service.WorkflowToolProviderController, "from_db", mock_from_db)
|
||||
mock_invalidate = MagicMock()
|
||||
monkeypatch.setattr(workflow_tools_manage_service.ToolProviderListCache, "invalidate_cache", mock_invalidate)
|
||||
|
||||
parameters = [{"name": "input", "description": "input", "form": "form"}]
|
||||
|
||||
with pytest.raises(WorkflowToolHumanInputNotSupportedError) as exc_info:
|
||||
workflow_tools_manage_service.WorkflowToolManageService.create_workflow_tool(
|
||||
user_id="user-id",
|
||||
tenant_id="tenant-id",
|
||||
workflow_app_id="app-id",
|
||||
name="tool_name",
|
||||
label="Tool",
|
||||
icon={"type": "emoji", "emoji": "tool"},
|
||||
description="desc",
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == "workflow_tool_human_input_not_supported"
|
||||
mock_from_db.assert_not_called()
|
||||
mock_invalidate.assert_not_called()
|
||||
|
||||
|
||||
def test_create_workflow_tool_success(monkeypatch):
|
||||
workflow = DummyWorkflow(graph_dict={"nodes": [{"id": "node_1", "data": {"type": "start"}}]})
|
||||
app = SimpleNamespace(workflow=workflow)
|
||||
|
||||
fake_session = _build_fake_session(app)
|
||||
monkeypatch.setattr(workflow_tools_manage_service.db, "session", fake_session)
|
||||
|
||||
dummy_session = DummySession()
|
||||
monkeypatch.setattr(workflow_tools_manage_service, "session_factory", DummySessionFactory(dummy_session))
|
||||
|
||||
mock_from_db = MagicMock()
|
||||
monkeypatch.setattr(workflow_tools_manage_service.WorkflowToolProviderController, "from_db", mock_from_db)
|
||||
mock_invalidate = MagicMock()
|
||||
monkeypatch.setattr(workflow_tools_manage_service.ToolProviderListCache, "invalidate_cache", mock_invalidate)
|
||||
|
||||
parameters = [{"name": "input", "description": "input", "form": "form"}]
|
||||
icon = {"type": "emoji", "emoji": "tool"}
|
||||
|
||||
result = workflow_tools_manage_service.WorkflowToolManageService.create_workflow_tool(
|
||||
user_id="user-id",
|
||||
tenant_id="tenant-id",
|
||||
workflow_app_id="app-id",
|
||||
name="tool_name",
|
||||
label="Tool",
|
||||
icon=icon,
|
||||
description="desc",
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
assert result == {"result": "success"}
|
||||
assert len(dummy_session.added) == 1
|
||||
created_provider = dummy_session.added[0]
|
||||
assert created_provider.name == "tool_name"
|
||||
assert created_provider.label == "Tool"
|
||||
assert created_provider.icon == json.dumps(icon)
|
||||
assert created_provider.version == workflow.version
|
||||
mock_from_db.assert_called_once()
|
||||
mock_invalidate.assert_called_once_with("tenant-id")
|
||||
Reference in New Issue
Block a user