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:
QuantumGhost
2026-01-21 16:29:49 +08:00
parent 05f9ea4220
commit 67c7ca7da7
6 changed files with 340 additions and 1 deletions

View File

@ -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

View File

@ -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]

View File

@ -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)

View File

@ -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.

View File

@ -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"

View File

@ -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")