diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py
index c55023ab353027..e0905d2e2e9245 100644
--- a/api/controllers/console/workspace/tool_providers.py
+++ b/api/controllers/console/workspace/tool_providers.py
@@ -61,12 +61,10 @@
ToolProviderType,
WorkflowToolParameterConfiguration,
)
-from core.tools.errors import WorkflowToolHumanInputNotSupportedError
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
-from libs.external_api import error_responses
from libs.helper import alphanumeric, dump_response, uuid_value
from libs.login import login_required
from models import Account
@@ -902,7 +900,6 @@ def post(self, req_data: ApiToolTestPayload, current_tenant_id: str):
@console_ns.route("/workspaces/current/tool-provider/workflow/create")
class ToolWorkflowProviderCreateApi(Resource):
- @error_responses(WorkflowToolHumanInputNotSupportedError)
@console_ns.expect(console_ns.models[WorkflowToolCreatePayload.__name__])
@console_ns.response(200, "Workflow tool created successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@@ -933,7 +930,6 @@ def post(self, req_data: WorkflowToolCreatePayload, tenant_id: str, user: Accoun
@console_ns.route("/workspaces/current/tool-provider/workflow/update")
class ToolWorkflowProviderUpdateApi(Resource):
- @error_responses(WorkflowToolHumanInputNotSupportedError)
@console_ns.expect(console_ns.models[WorkflowToolUpdatePayload.__name__])
@console_ns.response(200, "Workflow tool updated successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py
index e067f07b3cbfce..806a4e76397224 100644
--- a/api/core/app/apps/workflow/app_generator.py
+++ b/api/core/app/apps/workflow/app_generator.py
@@ -108,6 +108,7 @@ def generate(
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[Layer] = (),
+ allow_human_input: bool = True,
pause_state_config: PauseStateLayerConfig | None = None,
) -> Generator[Mapping[str, Any] | str]: ...
@@ -126,6 +127,7 @@ def generate(
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[Layer] = (),
+ allow_human_input: bool = True,
pause_state_config: PauseStateLayerConfig | None = None,
) -> Mapping[str, Any]: ...
@@ -144,6 +146,7 @@ def generate(
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[Layer] = (),
+ allow_human_input: bool = True,
pause_state_config: PauseStateLayerConfig | None = None,
) -> Mapping[str, Any] | Generator[Mapping[str, Any] | str]: ...
@@ -161,6 +164,7 @@ def generate(
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[Layer] = (),
+ allow_human_input: bool = True,
pause_state_config: PauseStateLayerConfig | None = None,
) -> Mapping[str, Any] | Generator[Mapping[str, Any] | str]:
with self._bind_file_access_scope(tenant_id=app_model.tenant_id, user=user, invoke_from=invoke_from):
@@ -222,6 +226,7 @@ def generate(
user_id=user.id,
stream=streaming,
invoke_from=invoke_from,
+ allow_human_input=allow_human_input,
call_depth=call_depth,
trace_manager=trace_manager,
workflow_execution_id=workflow_run_id,
diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py
index 70ad6483ad038e..1246cd49a027a4 100644
--- a/api/core/app/apps/workflow_app_runner.py
+++ b/api/core/app/apps/workflow_app_runner.py
@@ -54,6 +54,7 @@
resolve_workflow_node_class,
)
from core.workflow.nodes.agent.events import NodeRunAgentLogEvent
+from core.workflow.nodes.human_input.boundary import resolve_human_input_node_id
from core.workflow.nodes.human_input.callback import DifyHITLCallback
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired, PauseReason
@@ -536,9 +537,10 @@ def publish_human_input_results(
published_form_ids: set[str],
) -> None:
"""Publish materialized completions supplied by run orchestration."""
+ variable_pool = workflow_entry.graph_engine.runtime_state.variable_pool
for form_id, reason in pending_forms.items():
form = forms[form_id]
- node_id = reason.node_id
+ node_id = resolve_human_input_node_id(node_id=reason.node_id, form_id=form_id, variable_pool=variable_pool)
node_title = reason.node_title or form.definition.node_title or form.node_id
if form.status == HumanInputFormStatus.TIMEOUT:
self._publish_event(
@@ -603,7 +605,11 @@ def handle_event(
self._publish_event(
QueueHumanInputFormFilledEvent(
form_id=event.id,
- node_id=event.node_id,
+ node_id=resolve_human_input_node_id(
+ node_id=event.node_id,
+ form_id=event.id,
+ variable_pool=workflow_entry.graph_engine.runtime_state.variable_pool,
+ ),
node_type=event.node_type,
node_title=event.node_title,
rendered_content=event.rendered_content,
@@ -616,7 +622,11 @@ def handle_event(
self._publish_event(
QueueHumanInputFormTimeoutEvent(
form_id=event.id,
- node_id=event.node_id,
+ node_id=resolve_human_input_node_id(
+ node_id=event.node_id,
+ form_id=event.id,
+ variable_pool=workflow_entry.graph_engine.runtime_state.variable_pool,
+ ),
node_type=event.node_type,
node_title=event.node_title,
expiration_time=event.expiration_time,
diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py
index 48108494654c89..750124ab6c00c5 100644
--- a/api/core/app/entities/app_invoke_entities.py
+++ b/api/core/app/entities/app_invoke_entities.py
@@ -104,6 +104,7 @@ class DifyRunContext(BaseModel):
app_type: CreditUsageAppType | None = None
created_by: CreditUsageCreatedBy | None = None
trace_session_id: str | None = None
+ workflow_tool_invocation_id: str | None = None
@field_validator("created_by", mode="before")
@classmethod
@@ -345,6 +346,8 @@ class WorkflowAppGenerateEntity(AppGenerateEntity):
workflow_execution_timing: WorkflowExecutionTiming = Field(
default_factory=WorkflowExecutionTiming, exclude=True, repr=False
)
+ # Legacy synchronous Tool callers cannot resume a paused execution.
+ allow_human_input: bool = True
class SingleIterationRunEntity(BaseModel):
"""
diff --git a/api/core/repositories/celery_workflow_node_execution_repository.py b/api/core/repositories/celery_workflow_node_execution_repository.py
index e29c191a776ef8..cf6563ced11fd8 100644
--- a/api/core/repositories/celery_workflow_node_execution_repository.py
+++ b/api/core/repositories/celery_workflow_node_execution_repository.py
@@ -7,7 +7,7 @@
import logging
from collections.abc import Sequence
-from typing import override
+from typing import Any, override
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
@@ -20,10 +20,12 @@
SQLAlchemyWorkflowNodeExecutionRepository,
)
from graphon.entities import WorkflowNodeExecution
+from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter
from models import Account, CreatorUserRole, EndUser
from models.workflow import WorkflowNodeExecutionTriggeredFrom
from tasks.workflow_node_execution_tasks import (
save_workflow_node_execution_task,
+ save_workflow_node_executions_task,
)
logger = logging.getLogger(__name__)
@@ -94,6 +96,7 @@ def __init__(
# Extract user context
self._triggered_from = triggered_from
self._creator_user_id = user.id
+ self._user = user
# Determine user role based on user type
self._creator_user_role = CreatorUserRole.ACCOUNT if isinstance(user, Account) else CreatorUserRole.END_USER
@@ -119,6 +122,16 @@ def __init__(
self._triggered_from,
)
+ @override
+ def for_workflow_tool(self, app_id: str) -> "CeleryWorkflowNodeExecutionRepository":
+ return CeleryWorkflowNodeExecutionRepository(
+ session_factory=self._session_factory,
+ tenant_id=self._tenant_id,
+ user=self._user,
+ app_id=app_id,
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ )
+
@override
def save(self, execution: WorkflowNodeExecution):
"""
@@ -142,7 +155,7 @@ def save(self, execution: WorkflowNodeExecution):
self._workflow_execution_mapping[execution.workflow_execution_id].append(execution.id)
# Serialize execution for Celery task
- execution_data = execution.model_dump()
+ execution_data = self._serialize_execution(execution)
# Queue the save operation as a Celery task (fire and forget)
save_workflow_node_execution_task.delay(
@@ -162,6 +175,37 @@ def save(self, execution: WorkflowNodeExecution):
# For now, we'll re-raise the exception
raise
+ @override
+ def save_many(self, executions: Sequence[WorkflowNodeExecution]) -> None:
+ """Cache exact records and queue one storage task for the whole batch."""
+ if not executions:
+ return
+ for execution in executions:
+ self._execution_cache[execution.id] = execution
+ if execution.workflow_execution_id:
+ execution_ids = self._workflow_execution_mapping.setdefault(execution.workflow_execution_id, [])
+ if execution.id not in execution_ids:
+ execution_ids.append(execution.id)
+ save_workflow_node_executions_task.delay(
+ executions_data=[self._serialize_execution(execution) for execution in executions],
+ tenant_id=self._tenant_id,
+ app_id=self._app_id or "",
+ triggered_from=self._triggered_from.value if self._triggered_from else "",
+ creator_user_id=self._creator_user_id,
+ creator_user_role=self._creator_user_role.value,
+ )
+
+ @staticmethod
+ def _serialize_execution(execution: WorkflowNodeExecution) -> dict[str, Any]:
+ """Project runtime values before model serialization erases their segment types."""
+ converter = WorkflowRuntimeTypeConverter()
+ return {
+ **execution.model_dump(exclude={"inputs", "process_data", "outputs"}),
+ "inputs": converter.to_json_encodable(execution.inputs),
+ "process_data": converter.to_json_encodable(execution.process_data),
+ "outputs": converter.to_json_encodable(execution.outputs),
+ }
+
@override
def save_execution_data(self, execution: WorkflowNodeExecution) -> None:
"""`save` already queues the complete inputs, process data, and outputs."""
diff --git a/api/core/repositories/factory.py b/api/core/repositories/factory.py
index ff0fd6bf2f3b67..229c6a9a571f44 100644
--- a/api/core/repositories/factory.py
+++ b/api/core/repositories/factory.py
@@ -37,8 +37,16 @@ def save_synchronously(self, execution: WorkflowExecution) -> None:
class WorkflowNodeExecutionRepository(Protocol):
+ def for_workflow_tool(self, app_id: str) -> "WorkflowNodeExecutionRepository":
+ """Scope hidden Workflow Tool executions to their source app in the same tenant."""
+ ...
+
def save(self, execution: WorkflowNodeExecution): ...
+ def save_many(self, executions: Sequence[WorkflowNodeExecution]) -> None:
+ """Save exact records in this owner scope without uploading execution data."""
+ ...
+
def save_synchronously(self, execution: WorkflowNodeExecution) -> None: ...
def save_execution_data(self, execution: WorkflowNodeExecution): ...
@@ -49,7 +57,9 @@ def get_by_workflow_execution(
order_config: OrderConfig | None = None,
*,
include_paused: bool = False,
- ) -> Sequence[WorkflowNodeExecution]: ...
+ ) -> Sequence[WorkflowNodeExecution]:
+ """Read executions in this repository's app/origin scope, optionally including paused nodes."""
+ ...
class RepositoryImportError(Exception):
diff --git a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py
index 570bbbe10f28a5..dd0603b27bf756 100644
--- a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py
+++ b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py
@@ -10,7 +10,7 @@
from typing import Any, override
import psycopg2.errors
-from sqlalchemy import UnaryExpression, asc, desc, select
+from sqlalchemy import UnaryExpression, and_, asc, desc, or_, select
from sqlalchemy.engine import Engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
@@ -18,7 +18,8 @@
from configs import dify_config
from core.repositories.factory import OrderConfig, WorkflowNodeExecutionRepository
-from core.workflow.node_execution_process_data import preserve_workflow_agent_binding_id
+from core.workflow.node_execution import WorkflowNodeExecution as DifyWorkflowNodeExecution
+from core.workflow.node_execution_process_data import keep_agent_and_tool_ids
from extensions.ext_storage import storage
from graphon.entities import WorkflowNodeExecution
from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
@@ -111,6 +112,16 @@ def __init__(
# Initialize FileService for handling offloaded data
self._file_service = FileService(session_factory)
+ @override
+ def for_workflow_tool(self, app_id: str) -> "SQLAlchemyWorkflowNodeExecutionRepository":
+ return SQLAlchemyWorkflowNodeExecutionRepository(
+ session_factory=self._session_factory,
+ tenant_id=self._tenant_id,
+ user=self._user,
+ app_id=app_id,
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ )
+
def _create_truncator(self) -> VariableTruncator:
return VariableTruncator(
max_size_bytes=dify_config.WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE,
@@ -140,10 +151,12 @@ def _to_domain_model(self, db_model: WorkflowNodeExecutionModel) -> WorkflowNode
# Convert status to domain enum
status = WorkflowNodeExecutionStatus(db_model.status)
- domain_model = WorkflowNodeExecution(
+ domain_model = DifyWorkflowNodeExecution(
id=db_model.id,
node_execution_id=db_model.node_execution_id,
workflow_id=db_model.workflow_id,
+ triggered_from_workflow_id=db_model.triggered_from_workflow_id,
+ triggered_from_node_execution_id=db_model.triggered_from_node_execution_id,
workflow_execution_id=db_model.workflow_run_id,
index=db_model.index,
predecessor_node_id=db_model.predecessor_node_id,
@@ -225,6 +238,9 @@ def _to_db_model(self, domain_model: WorkflowNodeExecution) -> WorkflowNodeExecu
if self._app_id is not None:
db_model.app_id = self._app_id
db_model.workflow_id = domain_model.workflow_id
+ if isinstance(domain_model, DifyWorkflowNodeExecution):
+ db_model.triggered_from_workflow_id = domain_model.triggered_from_workflow_id
+ db_model.triggered_from_node_execution_id = domain_model.triggered_from_node_execution_id
db_model.triggered_from = self._triggered_from
db_model.workflow_run_id = domain_model.workflow_execution_id
db_model.index = domain_model.index
@@ -375,6 +391,62 @@ def _save_with_retry():
logger.exception("Failed to save workflow node execution after all retries")
raise
+ @override
+ def save_many(self, executions: Sequence[WorkflowNodeExecution]) -> None:
+ """Upsert a cleanup batch in one transaction, preserving existing offloads."""
+ if not executions:
+ return
+ db_models = [self._to_db_model(execution) for execution in executions]
+ with self._session_factory() as session, session.begin():
+ for db_model in db_models:
+ if db_model.status in (
+ WorkflowNodeExecutionStatus.RUNNING,
+ WorkflowNodeExecutionStatus.PAUSED,
+ WorkflowNodeExecutionStatus.RETRY,
+ ):
+ apply_workflow_stop_to_node(
+ db_model,
+ get_stopped_workflow_run(
+ session, tenant_id=self._tenant_id, workflow_run_id=db_model.workflow_run_id, lock=True
+ ),
+ )
+ query = WorkflowNodeExecutionModel.preload_offload_data(select(WorkflowNodeExecutionModel)).where(
+ WorkflowNodeExecutionModel.tenant_id == self._tenant_id,
+ WorkflowNodeExecutionModel.app_id == self._app_id,
+ WorkflowNodeExecutionModel.triggered_from == self._triggered_from,
+ or_(
+ *(
+ and_(
+ WorkflowNodeExecutionModel.id == model.id,
+ WorkflowNodeExecutionModel.workflow_id == model.workflow_id,
+ WorkflowNodeExecutionModel.workflow_run_id == model.workflow_run_id,
+ )
+ for model in db_models
+ )
+ ),
+ )
+ existing_models = {model.id: model for model in session.scalars(query)}
+ for db_model in db_models:
+ existing = existing_models.get(db_model.id)
+ if existing is None:
+ session.add(db_model)
+ continue
+ process_data = keep_agent_and_tool_ids(existing.process_data_dict, db_model.process_data_dict)
+ db_model.process_data = _deterministic_json_dump(process_data) if process_data is not None else None
+ if db_model.triggered_from_workflow_id is None:
+ db_model.triggered_from_workflow_id = existing.triggered_from_workflow_id
+ if db_model.triggered_from_node_execution_id is None:
+ db_model.triggered_from_node_execution_id = existing.triggered_from_node_execution_id
+ # Cleanup changes terminal state; replacing an offloaded preview would
+ # leave it inconsistent with the separately owned upload/file reference.
+ if existing.inputs_truncated:
+ db_model.inputs = existing.inputs
+ if existing.process_data_truncated:
+ db_model.process_data = existing.process_data
+ if existing.outputs_truncated:
+ db_model.outputs = existing.outputs
+ session.merge(db_model)
+
@override
def save_synchronously(self, execution: WorkflowNodeExecution) -> None:
"""Persist a caller row before an Agent v2 participant is materialized."""
@@ -406,7 +478,7 @@ def _persist_to_database(self, db_model: WorkflowNodeExecutionModel):
existing = session.get(WorkflowNodeExecutionModel, db_model.id)
if existing:
- merged_process_data = preserve_workflow_agent_binding_id(
+ merged_process_data = keep_agent_and_tool_ids(
existing.process_data_dict,
db_model.process_data_dict,
)
@@ -469,7 +541,7 @@ def save_execution_data(self, execution: WorkflowNodeExecution):
else:
db_model.outputs = self._json_encode(domain_model.outputs)
- process_data = preserve_workflow_agent_binding_id(db_model.process_data_dict, domain_model.process_data)
+ process_data = keep_agent_and_tool_ids(db_model.process_data_dict, domain_model.process_data)
if process_data is not None:
result = self._truncate_and_upload(
process_data,
@@ -477,7 +549,7 @@ def save_execution_data(self, execution: WorkflowNodeExecution):
ExecutionOffLoadType.PROCESS_DATA,
)
if result is not None:
- truncated_process_data = preserve_workflow_agent_binding_id(
+ truncated_process_data = keep_agent_and_tool_ids(
process_data,
result.truncated_value,
)
@@ -518,6 +590,9 @@ def get_db_models_by_workflow_run(
The returned models have `offload_data` preloaded, along with the associated
`inputs_file` and `outputs_file` data.
+ Reads preserve this repository's app/origin scope, including trace export.
+ Paused nodes stay hidden unless resume hydration requests include_paused.
+
This method directly returns database models without converting to domain models,
which is useful when you need to access database-specific fields like triggered_from.
It also updates the in-memory cache with the retrieved models.
diff --git a/api/core/tools/errors.py b/api/core/tools/errors.py
index 2b26832b44a6a0..ec366f99ec360b 100644
--- a/api/core/tools/errors.py
+++ b/api/core/tools/errors.py
@@ -1,5 +1,4 @@
from core.tools.entities.tool_entities import ToolInvokeMeta
-from libs.exception import BaseHTTPException
class ToolProviderNotFoundError(ValueError):
@@ -49,12 +48,6 @@ def __init__(self, provider_name: str, tenant_id: str):
super().__init__(f"api provider {provider_name} does not exist")
-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
diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py
index 61935b9d231af2..e861a7d81c4924 100644
--- a/api/core/tools/tool_manager.py
+++ b/api/core/tools/tool_manager.py
@@ -478,22 +478,35 @@ def get_workflow_tool_runtime(
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: "VariablePool | None" = None,
execution_driver: "WorkflowRunDriver | None" = None,
+ workflow_tool_definition: WorkflowTool | None = None,
) -> Tool:
"""
get the workflow tool runtime
"""
- tool_runtime = cls.get_tool_runtime(
- provider_type=workflow_tool.provider_type,
- provider_id=workflow_tool.provider_id,
- tool_name=workflow_tool.tool_name,
- tenant_id=tenant_id,
- user_id=user_id,
- invoke_from=invoke_from,
- tool_invoke_from=ToolInvokeFrom.WORKFLOW,
- credential_id=workflow_tool.credential_id,
- execution_driver=execution_driver,
- )
+ if workflow_tool_definition is None:
+ tool_runtime = cls.get_tool_runtime(
+ provider_type=workflow_tool.provider_type,
+ provider_id=workflow_tool.provider_id,
+ tool_name=workflow_tool.tool_name,
+ tenant_id=tenant_id,
+ user_id=user_id,
+ invoke_from=invoke_from,
+ tool_invoke_from=ToolInvokeFrom.WORKFLOW,
+ credential_id=workflow_tool.credential_id,
+ execution_driver=execution_driver,
+ )
+ else:
+ tool_runtime = workflow_tool_definition.fork_tool_runtime(
+ runtime=ToolRuntime(
+ tenant_id=tenant_id,
+ user_id=user_id,
+ credentials={},
+ invoke_from=invoke_from,
+ tool_invoke_from=ToolInvokeFrom.WORKFLOW,
+ )
+ )
+ tool_runtime.execution_driver = execution_driver
parameters = tool_runtime.get_merged_runtime_parameters()
runtime_parameters = cls._convert_tool_parameters_type(
diff --git a/api/core/tools/utils/workflow_configuration_sync.py b/api/core/tools/utils/workflow_configuration_sync.py
index 45718cadb6bc65..af5e604ce4cee7 100644
--- a/api/core/tools/utils/workflow_configuration_sync.py
+++ b/api/core/tools/utils/workflow_configuration_sync.py
@@ -2,8 +2,6 @@
from typing import Any
from core.tools.entities.tool_entities import WorkflowToolParameterConfiguration
-from core.tools.errors import WorkflowToolHumanInputNotSupportedError
-from graphon.enums import BuiltinNodeTypes
from graphon.nodes.base.entities import OutputVariableEntity
from graphon.variables.input_entities import VariableEntity
@@ -45,13 +43,6 @@ def get_workflow_graph_output(cls, graph: Mapping[str, Any]) -> Sequence[OutputV
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") == BuiltinNodeTypes.HUMAN_INPUT:
- raise WorkflowToolHumanInputNotSupportedError()
-
@classmethod
def check_is_synced(
cls, variables: list[VariableEntity], tool_configurations: list[WorkflowToolParameterConfiguration]
diff --git a/api/core/tools/workflow_as_tool/provider.py b/api/core/tools/workflow_as_tool/provider.py
index 1611cd1d63a46d..d613fc0ffc47cf 100644
--- a/api/core/tools/workflow_as_tool/provider.py
+++ b/api/core/tools/workflow_as_tool/provider.py
@@ -75,7 +75,7 @@ def from_db(cls, db_provider: WorkflowToolProvider) -> WorkflowToolProviderContr
)
controller.tools = [
- controller._get_db_provider_tool(db_provider, app, session=session, user=user),
+ controller._get_db_provider_tool(db_provider, session=session, user=user),
]
return controller
@@ -88,7 +88,6 @@ def provider_type(self) -> ToolProviderType:
def _get_db_provider_tool(
self,
db_provider: WorkflowToolProvider,
- app: App,
*,
session: Session,
user: Account | None = None,
@@ -96,7 +95,6 @@ def _get_db_provider_tool(
"""
get db provider tool
:param db_provider: the db provider
- :param app: the app
:return: the tool
"""
workflow: Workflow | None = session.scalar(
@@ -108,6 +106,11 @@ def _get_db_provider_tool(
if not workflow:
raise ValueError("workflow not found")
+ return self.build_tool(db_provider, workflow, author=user.name if user else "")
+
+ @staticmethod
+ def build_tool(db_provider: WorkflowToolProvider, workflow: Workflow, *, author: str) -> WorkflowTool:
+ """Build a tool from an already-loaded provider and its pinned definition."""
# fetch start node
graph: Mapping = workflow.graph_dict
features_dict: Mapping = workflow.features_dict
@@ -183,10 +186,10 @@ def fetch_workflow_variable(variable_name: str) -> VariableEntity | None:
workflow_as_tool_id=db_provider.id,
entity=ToolEntity(
identity=ToolIdentity(
- author=user.name if user else "",
+ author=author,
name=db_provider.name,
label=I18nObject(en_US=db_provider.label, zh_Hans=db_provider.label),
- provider=self.provider_id,
+ provider=db_provider.id,
icon=db_provider.icon,
),
description=ToolDescription(
@@ -199,11 +202,8 @@ def fetch_workflow_variable(variable_name: str) -> VariableEntity | None:
runtime=ToolRuntime(
tenant_id=db_provider.tenant_id,
),
- workflow_app_id=app.id,
- workflow_entities={
- "app": app,
- "workflow": workflow,
- },
+ workflow_app_id=workflow.app_id,
+ workflow_id=workflow.id,
version=db_provider.version,
workflow_call_depth=0,
label=db_provider.label,
@@ -237,7 +237,7 @@ def get_tools(self, tenant_id: str) -> list[WorkflowTool]:
raise ValueError("app not found")
user = session.get(Account, db_provider.user_id) if db_provider.user_id else None
- self.tools = [self._get_db_provider_tool(db_provider, app, session=session, user=user)]
+ self.tools = [self._get_db_provider_tool(db_provider, session=session, user=user)]
return self.tools
diff --git a/api/core/tools/workflow_as_tool/repository.py b/api/core/tools/workflow_as_tool/repository.py
new file mode 100644
index 00000000000000..05f082a6201f0b
--- /dev/null
+++ b/api/core/tools/workflow_as_tool/repository.py
@@ -0,0 +1,22 @@
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from enums import WorkflowKind
+from graphon.variables import VariableBase
+
+if TYPE_CHECKING:
+ from core.tools.workflow_as_tool.tool import WorkflowTool
+
+
+@dataclass(frozen=True, slots=True)
+class WorkflowToolSource:
+ """Database-independent definition of a pinned Workflow Tool source."""
+
+ app_id: str
+ workflow_id: str
+ graph_config: Mapping[str, Any]
+ features_dict: Mapping[str, Any]
+ environment_variables: Sequence[VariableBase]
+ workflow_kind: WorkflowKind
+ tool: "WorkflowTool | None" = None
diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py
index a7ebe79e8fbc20..dc46263692af91 100644
--- a/api/core/tools/workflow_as_tool/tool.py
+++ b/api/core/tools/workflow_as_tool/tool.py
@@ -27,6 +27,7 @@
from core.tools.errors import ToolInvokeError
from core.workflow.file_reference import resolve_file_record_id
from factories.file_factory import build_from_mapping
+from graphon.enums import WorkflowExecutionStatus
from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod
from graphon.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata
from models import Account, Tenant
@@ -52,9 +53,9 @@ class WorkflowTool(Tool):
def __init__(
self,
workflow_app_id: str,
+ workflow_id: str,
workflow_as_tool_id: str,
version: str,
- workflow_entities: dict[str, Any],
workflow_call_depth: int,
entity: ToolEntity,
runtime: ToolRuntime,
@@ -62,9 +63,9 @@ def __init__(
execution_driver: WorkflowRunDriver | None = None,
):
self.workflow_app_id = workflow_app_id
+ self.workflow_id = workflow_id
self.workflow_as_tool_id = workflow_as_tool_id
self.version = version
- self.workflow_entities = workflow_entities
self.workflow_call_depth = workflow_call_depth
self.label = label
self.execution_driver = execution_driver
@@ -136,10 +137,14 @@ def _invoke(
# because workflow pausing mechanisms (such as HumanInput) are not
# supported within WorkflowTool execution context.
pause_state_config=None,
+ allow_human_input=False,
)
assert isinstance(result, dict)
data = result.get("data", {})
+ if data.get("status") == WorkflowExecutionStatus.PAUSED:
+ raise ToolInvokeError("Paused Workflow Tool execution requires Engine-managed container execution.")
+
if err := data.get("error"):
raise ToolInvokeError(err)
@@ -165,6 +170,15 @@ def _invoke(
def latest_usage(self) -> LLMUsage:
return self._latest_usage
+ def prepare_container_inputs(
+ self,
+ tool_parameters: Mapping[str, Any],
+ ) -> tuple[dict[str, Any], list[dict[str, str | None]]]:
+ """Merge configured parameters and convert them into workflow inputs."""
+ merged_parameters = {**tool_parameters, **self.runtime.runtime_parameters}
+ typed_parameters = self._transform_tool_parameters_type(merged_parameters)
+ return self._transform_args(tool_parameters=typed_parameters)
+
@classmethod
def _derive_usage_from_result(cls, data: Mapping[str, Any]) -> LLMUsage:
usage_dict = cls._extract_usage_dict(data)
@@ -228,8 +242,8 @@ def fork_tool_runtime(self, runtime: ToolRuntime) -> WorkflowTool:
entity=self.entity.model_copy(),
runtime=runtime,
workflow_app_id=self.workflow_app_id,
+ workflow_id=self.workflow_id,
workflow_as_tool_id=self.workflow_as_tool_id,
- workflow_entities=self.workflow_entities,
workflow_call_depth=self.workflow_call_depth,
version=self.version,
label=self.label,
diff --git a/api/core/workflow/node_execution.py b/api/core/workflow/node_execution.py
new file mode 100644
index 00000000000000..efae91e9435a59
--- /dev/null
+++ b/api/core/workflow/node_execution.py
@@ -0,0 +1,9 @@
+"""Dify persistence identity alongside Graphon's workflow execution state."""
+
+from graphon.entities import WorkflowNodeExecution as GraphonWorkflowNodeExecution
+
+
+class WorkflowNodeExecution(GraphonWorkflowNodeExecution):
+ # Null for executions that were not invoked by a Workflow Tool.
+ triggered_from_workflow_id: str | None = None
+ triggered_from_node_execution_id: str | None = None
diff --git a/api/core/workflow/node_execution_process_data.py b/api/core/workflow/node_execution_process_data.py
index 4133feb1b21385..1d5fbf49d28281 100644
--- a/api/core/workflow/node_execution_process_data.py
+++ b/api/core/workflow/node_execution_process_data.py
@@ -2,26 +2,36 @@
from typing import Any
WORKFLOW_AGENT_BINDING_ID_KEY = "workflow_agent_binding_id"
+WORKFLOW_TOOL_INVOCATION_ID_KEY = "workflow_tool_invocation_id"
+WORKFLOW_TOOL_ROOT_APP_ID_KEY = "workflow_tool_root_app_id"
-def preserve_workflow_agent_binding_id(
- identity_source: Mapping[str, Any] | None,
+def keep_agent_and_tool_ids(
+ source_process_data: Mapping[str, Any] | None,
process_data: Mapping[str, Any] | None,
) -> dict[str, Any] | None:
- source_id = (identity_source or {}).get(WORKFLOW_AGENT_BINDING_ID_KEY)
- target_id = (process_data or {}).get(WORKFLOW_AGENT_BINDING_ID_KEY)
- for value in (source_id, target_id):
- if value is not None and not isinstance(value, str):
- raise ValueError("workflow_agent_binding_id must be a string")
- if source_id is not None and target_id is not None and source_id != target_id:
- raise ValueError("workflow_agent_binding_id does not match")
+ """Keep persisted Agent and Workflow Tool ownership through node event updates."""
+ updated_process_data = dict(process_data or {})
+ for key in (
+ WORKFLOW_AGENT_BINDING_ID_KEY,
+ WORKFLOW_TOOL_INVOCATION_ID_KEY,
+ WORKFLOW_TOOL_ROOT_APP_ID_KEY,
+ ):
+ source_id = (source_process_data or {}).get(key)
+ target_id = (process_data or {}).get(key)
+ for value in (source_id, target_id):
+ if value is not None and not isinstance(value, str):
+ raise ValueError(f"{key} must be a string")
+ if source_id is not None and target_id is not None and source_id != target_id:
+ raise ValueError(f"{key} does not match")
+ if source_id is not None:
+ updated_process_data[key] = source_id
+ return None if process_data is None and not updated_process_data else updated_process_data
- if process_data is None and source_id is None:
- return None
- merged = dict(process_data or {})
- if source_id is not None:
- merged[WORKFLOW_AGENT_BINDING_ID_KEY] = source_id
- return merged
-
-__all__ = ["WORKFLOW_AGENT_BINDING_ID_KEY", "preserve_workflow_agent_binding_id"]
+__all__ = [
+ "WORKFLOW_AGENT_BINDING_ID_KEY",
+ "WORKFLOW_TOOL_INVOCATION_ID_KEY",
+ "WORKFLOW_TOOL_ROOT_APP_ID_KEY",
+ "keep_agent_and_tool_ids",
+]
diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py
index d7b1aff4aa9393..ee1d3cba3a3577 100644
--- a/api/core/workflow/node_factory.py
+++ b/api/core/workflow/node_factory.py
@@ -56,6 +56,7 @@
from core.workflow.nodes.human_input.entities import HumanInputNodeData as DifyHumanInputNodeData
from core.workflow.system_variables import SystemVariableKey, get_system_text, system_variable_selector
from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer
+from core.workflow.workflow_tool_node import DifyWorkflowToolNode
from extensions.application_services.data_sources import build_data_source_credentials
from graphon.entities.base_node_data import BaseNodeData
from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter
@@ -73,11 +74,13 @@
from graphon.nodes.llm.entities import LLMNodeData
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData
+from graphon.nodes.tool.entities import ToolProviderType
from graphon.variables.segments import ArrayObjectSegment, ObjectSegment
from models.model import Conversation
if TYPE_CHECKING:
from core.app.apps.workflow_app_runner import WorkflowRunDriver
+ from core.tools.workflow_as_tool.tool import WorkflowTool
from graphon.runtime import InitParams, RuntimeState
LATEST_VERSION = "latest"
@@ -313,24 +316,43 @@ def from_graph_init_context(
*,
graph_init_context: DifyGraphInitContext,
runtime_state: "RuntimeState",
+ human_input_run_context: Mapping[str, Any] | DifyRunContext | None = None,
+ use_workflow_tool_containers: bool = True,
+ workflow_tools: dict[str, "WorkflowTool"] | None = None,
) -> "DifyNodeFactory":
"""Bridge Dify's explicit init context into the current `graphon` API."""
return cls(
init_params=graph_init_context.to_graph_init_params(),
runtime_state=runtime_state,
+ human_input_run_context=human_input_run_context,
+ use_workflow_tool_containers=use_workflow_tool_containers,
execution_driver=graph_init_context.execution_driver,
+ workflow_tools=workflow_tools,
)
def __init__(
self,
init_params: "InitParams",
runtime_state: "RuntimeState",
+ human_input_run_context: Mapping[str, Any] | DifyRunContext | None = None,
+ use_workflow_tool_containers: bool = True,
+ workflow_tools: dict[str, "WorkflowTool"] | None = None,
execution_driver: "WorkflowRunDriver | None" = None,
) -> None:
self.init_params = init_params
self.runtime_state = runtime_state
self._dify_context = self._resolve_dify_context(init_params.run_context)
+ self._use_workflow_tool_containers = use_workflow_tool_containers
self._execution_driver = execution_driver
+ self._human_input_run_context = (
+ self._dify_context
+ if human_input_run_context is None
+ else (
+ human_input_run_context
+ if isinstance(human_input_run_context, DifyRunContext)
+ else self._resolve_dify_context(human_input_run_context)
+ )
+ )
self._code_executor: CodeExecutorProtocol = DefaultWorkflowCodeExecutor()
self._code_limits = CodeNodeLimits(
max_string_length=dify_config.CODE_MAX_STRING_LENGTH,
@@ -361,14 +383,17 @@ def __init__(
conversation_id_getter=self._conversation_id,
)
self._human_input_runtime = DifyHumanInputNodeRuntime(
- self._dify_context,
+ self._human_input_run_context,
workflow_execution_id_getter=lambda: get_system_text(
self.runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
),
conversation_id_getter=self._conversation_id,
)
- self._tool_runtime = DifyToolNodeRuntime(self._dify_context, execution_driver=execution_driver)
+ self.workflow_tools = workflow_tools if workflow_tools is not None else {}
+ self._tool_runtime = DifyToolNodeRuntime(
+ self._dify_context, execution_driver=execution_driver, workflow_tools=self.workflow_tools
+ )
self._http_request_file_manager = file_manager
self._document_extractor_unstructured_api_config = UnstructuredApiConfig(
api_url=dify_config.UNSTRUCTURED_API_URL,
@@ -395,6 +420,9 @@ def with_runtime_state(self, runtime_state: "RuntimeState") -> "DifyNodeFactory"
return DifyNodeFactory(
init_params=self.init_params,
runtime_state=runtime_state,
+ human_input_run_context=self._human_input_run_context,
+ use_workflow_tool_containers=self._use_workflow_tool_containers,
+ workflow_tools=self.workflow_tools,
execution_driver=self._execution_driver,
)
@@ -408,6 +436,10 @@ def with_graph_config(self, graph_config: Mapping[str, Any]) -> "DifyNodeFactory
def execution_driver(self) -> "WorkflowRunDriver | None":
return self._execution_driver
+ @property
+ def human_input_run_context(self) -> DifyRunContext:
+ return self._human_input_run_context
+
@staticmethod
def _resolve_dify_context(run_context: Mapping[str, Any]) -> DifyRunContext:
raw_ctx = run_context.get(DIFY_RUN_CONTEXT_KEY)
@@ -531,7 +563,7 @@ def _validate_node_config(
adapted_node_config = adapt_node_config_for_graph(node_config)
typed_node_config = NodeConfigDictAdapter.validate_python(adapted_node_config)
node_data = typed_node_config["data"]
- node_class = self._resolve_node_class(
+ node_class = self._resolve_node_class_for_factory(
node_type=node_data.type,
node_version=str(node_data.version),
node_data=node_data,
@@ -549,6 +581,25 @@ def _validate_resolved_node_data(node_class: type[Node], node_data: BaseNodeData
return cast("BaseNodeData", validate_node_data(node_data))
return node_data
+ def _resolve_node_class_for_factory(
+ self,
+ *,
+ node_type: NodeType,
+ node_version: str,
+ node_data: BaseNodeData,
+ ) -> type[Node]:
+ if (
+ self._use_workflow_tool_containers
+ and node_type == BuiltinNodeTypes.TOOL
+ and node_data.model_dump().get("provider_type") == ToolProviderType.WORKFLOW
+ ):
+ return DifyWorkflowToolNode
+ return self._resolve_node_class(
+ node_type=node_type,
+ node_version=node_version,
+ node_data=node_data,
+ )
+
@staticmethod
def _resolve_node_class(
*,
diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py
index eb0eef4742a690..334acaa7a3614c 100644
--- a/api/core/workflow/node_runtime.py
+++ b/api/core/workflow/node_runtime.py
@@ -74,6 +74,7 @@
ToolRuntimeMessage,
ToolRuntimeParameter,
)
+from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter
from models.dataset import SegmentAttachmentBinding
from models.model import UploadFile
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
@@ -88,6 +89,7 @@
parse_human_input_delivery_methods,
)
from .system_variables import SystemVariableKey, get_system_text
+from .workflow_tool_container_types import WorkflowToolContainerPayload
class PollingLLMRuntimeProtocol(Protocol):
@@ -120,6 +122,7 @@ def check_llm_polling(
from core.app.apps.workflow_app_runner import WorkflowRunDriver
from core.tools.__base.tool import Tool
from core.tools.entities.tool_entities import ToolInvokeMessage as CoreToolInvokeMessage
+ from core.tools.workflow_as_tool.tool import WorkflowTool
from graphon.nodes.llm.file_saver import LLMFileSaver
from graphon.nodes.tool.entities import ToolNodeData
@@ -530,11 +533,13 @@ def __init__(
run_context: Mapping[str, Any] | DifyRunContext,
session_maker: sessionmaker[Session] | None = None,
execution_driver: WorkflowRunDriver | None = None,
+ workflow_tools: Mapping[str, WorkflowTool] | None = None,
) -> None:
self._run_context = resolve_dify_run_context(run_context)
self._file_reference_factory = DifyFileReferenceFactory(self._run_context)
self._session_maker = session_maker
self._execution_driver = execution_driver
+ self._workflow_tools = workflow_tools if workflow_tools is not None else {}
@property
def file_reference_factory(self) -> FileReferenceFactoryProtocol:
@@ -563,6 +568,11 @@ def get_runtime(
self._run_context.invoke_from,
variable_pool,
execution_driver=self._execution_driver,
+ workflow_tool_definition=(
+ self._workflow_tools.get(node_data.provider_id)
+ if self._is_workflow_tool_provider(node_data)
+ else None
+ ),
)
except ToolNodeError:
raise
@@ -672,6 +682,39 @@ def get_usage(
return LLMUsage.model_validate(latest)
return LLMUsage.empty_usage()
+ def build_workflow_tool_container_payload(
+ self,
+ *,
+ tool_runtime: ToolRuntimeHandle,
+ tool_parameters: Mapping[str, Any],
+ inputs_for_log: Mapping[str, Any],
+ workflow_call_depth: int,
+ ) -> WorkflowToolContainerPayload:
+ """Prepare a serializable request for Engine-owned Workflow Tool execution."""
+ from core.tools.workflow_as_tool.tool import WorkflowTool
+
+ tool = self._binding_from_handle(tool_runtime).tool
+ if not isinstance(tool, WorkflowTool):
+ raise ToolRuntimeResolutionError("resolved tool is not a Workflow Tool")
+
+ inputs, system_files = tool.prepare_container_inputs(tool_parameters)
+ json_inputs = WorkflowRuntimeTypeConverter().to_json_encodable(
+ {
+ "inputs": inputs,
+ "system_files": system_files,
+ "inputs_for_log": dict(inputs_for_log),
+ }
+ )
+ return WorkflowToolContainerPayload(
+ source_app_id=tool.workflow_app_id,
+ source_workflow_id=tool.workflow_id,
+ source_workflow_version=tool.version,
+ inputs=json_inputs["inputs"],
+ system_files=json_inputs["system_files"],
+ inputs_for_log=json_inputs["inputs_for_log"],
+ call_depth=workflow_call_depth + 1,
+ )
+
def resolve_provider_icons(
self,
*,
diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py
index eb050a8e789535..771a430e3ddf8a 100644
--- a/api/core/workflow/nodes/agent_v2/agent_node.py
+++ b/api/core/workflow/nodes/agent_v2/agent_node.py
@@ -23,6 +23,7 @@
AgentBackendValidationError,
)
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
+from core.workflow.node_execution_process_data import WORKFLOW_TOOL_INVOCATION_ID_KEY
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.engine_events import NodeRunPauseRequestedEvent
from graphon.enums import (
@@ -177,6 +178,7 @@ def _run_inner(
node_id=self._node_id,
node_execution_id=self.execution_id,
conversation_id=conversation_id,
+ workflow_tool_invocation_id=dify_ctx.workflow_tool_invocation_id,
)
bundle = self._binding_resolver.resolve(
tenant_id=dify_ctx.tenant_id,
@@ -185,7 +187,7 @@ def _run_inner(
node_id=self._node_id,
binding_id=existing_scope.workflow_agent_binding_id if existing_scope is not None else None,
snapshot_id=existing_scope.agent_config_snapshot_id if existing_scope is not None else None,
- conversation_id=conversation_id,
+ conversation_id=conversation_id if dify_ctx.workflow_tool_invocation_id is None else None,
)
except WorkflowAgentBindingError as error:
yield self._failure_event(
@@ -213,6 +215,8 @@ def _run_inner(
"workflow_agent_binding_id": bundle.binding.id,
}
)
+ if dify_ctx.workflow_tool_invocation_id is not None:
+ process_data[WORKFLOW_TOOL_INVOCATION_ID_KEY] = dify_ctx.workflow_tool_invocation_id
session_scope = existing_scope or WorkflowAgentSessionScope(
tenant_id=dify_ctx.tenant_id,
app_id=dify_ctx.app_id,
@@ -224,6 +228,7 @@ def _run_inner(
agent_id=bundle.agent.id,
agent_config_snapshot_id=bundle.snapshot.id,
conversation_id=conversation_id,
+ workflow_tool_invocation_id=dify_ctx.workflow_tool_invocation_id,
)
node_job = WorkflowNodeJobConfig.model_validate(bundle.binding.node_job_config_dict)
diff --git a/api/core/workflow/nodes/agent_v2/session_store.py b/api/core/workflow/nodes/agent_v2/session_store.py
index 0392ba72729b43..31cea5ab088c53 100644
--- a/api/core/workflow/nodes/agent_v2/session_store.py
+++ b/api/core/workflow/nodes/agent_v2/session_store.py
@@ -11,10 +11,9 @@
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
+from core.workflow.node_execution_process_data import WORKFLOW_TOOL_INVOCATION_ID_KEY
from models.agent import (
AgentConfigVersionKind,
- AgentWorkingResourceStatus,
- AgentWorkspace,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
)
@@ -38,16 +37,20 @@ def resolve_workflow_agent_workspace_owner_scope(
node_id: str,
workflow_agent_binding_id: str,
node_execution_id: str | None = None,
+ workflow_tool_invocation_id: str | None = None,
) -> WorkspaceOwnerScope:
"""Choose the Workspace owner for a workflow Agent participant.
Chatflow runs carry ``conversation_id`` and should persist Agent memory across
- turns the same way standalone Agent Apps do. Pure workflow runs keep the
- per-run ``WORKFLOW_RUN`` scope.
+ turns the same way standalone Agent Apps do. Workflow Tool participants keep
+ their invocation scope and the owning run's lifetime, even inside Chatflows.
"""
owner_scope_key = f"{node_id}:{workflow_agent_binding_id}"
- if conversation_id:
+ if workflow_tool_invocation_id is not None:
+ # Partition repeated Tool calls without splitting loop executions inside a call.
+ owner_scope_key = f"workflow-tool:{workflow_tool_invocation_id}:{owner_scope_key}"
+ if conversation_id and workflow_tool_invocation_id is None:
return WorkspaceOwnerScope(
tenant_id=tenant_id,
app_id=app_id,
@@ -76,6 +79,7 @@ class WorkflowAgentSessionScope:
agent_id: str
agent_config_snapshot_id: str
conversation_id: str | None = None
+ workflow_tool_invocation_id: str | None = None
@property
def workspace_owner(self) -> WorkspaceOwnerScope:
@@ -87,6 +91,7 @@ def workspace_owner(self) -> WorkspaceOwnerScope:
node_id=self.node_id,
workflow_agent_binding_id=self.workflow_agent_binding_id,
node_execution_id=self.node_execution_id,
+ workflow_tool_invocation_id=self.workflow_tool_invocation_id,
)
@@ -127,6 +132,7 @@ def load_existing_node_execution_scope(
node_id: str,
node_execution_id: str,
conversation_id: str | None = None,
+ workflow_tool_invocation_id: str | None = None,
) -> WorkflowAgentSessionScope | None:
"""Return the generation pinned by an existing node execution participant."""
@@ -149,6 +155,8 @@ def load_existing_node_execution_scope(
workflow_agent_binding_id = process_data.get("workflow_agent_binding_id")
if not isinstance(workflow_agent_binding_id, str):
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity is missing")
+ if process_data.get(WORKFLOW_TOOL_INVOCATION_ID_KEY) != workflow_tool_invocation_id:
+ raise AgentWorkspaceNotFoundError("Workflow node execution caller invocation does not match")
owner_scope = resolve_workflow_agent_workspace_owner_scope(
tenant_id=tenant_id,
app_id=app_id,
@@ -157,6 +165,7 @@ def load_existing_node_execution_scope(
node_id=node_id,
workflow_agent_binding_id=workflow_agent_binding_id,
node_execution_id=node_execution_id,
+ workflow_tool_invocation_id=workflow_tool_invocation_id,
)
binding = AgentWorkspaceService.get_active_binding(
session=session,
@@ -177,6 +186,7 @@ def load_existing_node_execution_scope(
agent_id=binding.agent_id,
agent_config_snapshot_id=binding.agent_config_version_id,
conversation_id=conversation_id,
+ workflow_tool_invocation_id=workflow_tool_invocation_id,
)
def load_or_create_node_execution_session(
@@ -192,6 +202,9 @@ def load_or_create_node_execution_session(
stored_workflow_binding_id = process_data.get("workflow_agent_binding_id")
if stored_workflow_binding_id is not None and stored_workflow_binding_id != scope.workflow_agent_binding_id:
raise AgentWorkspaceNotFoundError("Workflow node execution caller identity does not match")
+ stored_invocation_id = process_data.get(WORKFLOW_TOOL_INVOCATION_ID_KEY)
+ if stored_invocation_id is not None and stored_invocation_id != scope.workflow_tool_invocation_id:
+ raise AgentWorkspaceNotFoundError("Workflow node execution caller invocation does not match")
binding_id = execution.agent_workspace_binding_id
if binding_id is None:
@@ -201,6 +214,8 @@ def load_or_create_node_execution_session(
home_snapshot_id=home_snapshot_id,
)
execution.agent_workspace_binding_id = binding.id
+ if scope.workflow_tool_invocation_id is not None:
+ process_data[WORKFLOW_TOOL_INVOCATION_ID_KEY] = scope.workflow_tool_invocation_id
execution.process_data = json.dumps(
{
**process_data,
@@ -247,28 +262,10 @@ def save_active_snapshot(
def retire_workflow_run(self, *, tenant_id: str, app_id: str, workflow_run_id: str) -> list[str]:
"""Retire active Workspaces, commit, and return active or already-retired IDs for collection."""
- retired: list[str] = []
with session_factory.create_session() as session:
- workspaces = session.scalars(
- select(AgentWorkspace).where(
- AgentWorkspace.tenant_id == tenant_id,
- AgentWorkspace.app_id == app_id,
- AgentWorkspace.owner_type == AgentWorkspaceOwnerType.WORKFLOW_RUN,
- AgentWorkspace.owner_id == workflow_run_id,
- AgentWorkspace.status.in_((AgentWorkingResourceStatus.ACTIVE, AgentWorkingResourceStatus.RETIRED)),
- )
- ).all()
- for workspace in workspaces:
- if workspace.status == AgentWorkingResourceStatus.RETIRED:
- retired.append(workspace.id)
- continue
- workspace_id = AgentWorkspaceService.retire_workspace(
- session=session,
- tenant_id=tenant_id,
- workspace_id=workspace.id,
- )
- if workspace_id is not None:
- retired.append(workspace_id)
+ retired = AgentWorkspaceService.retire_workflow_run(
+ session=session, tenant_id=tenant_id, app_id=app_id, workflow_run_id=workflow_run_id
+ )
session.commit()
return retired
@@ -323,7 +320,7 @@ def _resolve_or_create_binding(
) -> AgentWorkspaceBinding:
"""Reuse a conversation-scoped participant or allocate a new one."""
- if scope.conversation_id is not None:
+ if scope.workspace_owner.owner_type == AgentWorkspaceOwnerType.CONVERSATION:
existing_binding = WorkflowAgentWorkspaceStore.load_active_participant(
session=session,
scope=scope.workspace_owner,
diff --git a/api/core/workflow/nodes/human_input/boundary.py b/api/core/workflow/nodes/human_input/boundary.py
index 92e5669d69b974..1781839ba04ece 100644
--- a/api/core/workflow/nodes/human_input/boundary.py
+++ b/api/core/workflow/nodes/human_input/boundary.py
@@ -16,6 +16,7 @@
from graphon.entities.pause_reason import HitlRequired
from graphon.enums import BuiltinNodeTypes
from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool
+from graphon.variables.segments import StringSegment
from .constants import OUTPUT_FIELD_ACTION_ID, OUTPUT_FIELD_ACTION_VALUE, OUTPUT_FIELD_RENDERED_CONTENT, TIMEOUT_HANDLE
from .pause_reason import HumanInputRequired
@@ -87,17 +88,28 @@ def _completion_event(
)
+def human_input_container_selector(form_id: str) -> tuple[str, str]:
+ return ("__dify_workflow_tool_hitl__", form_id)
+
+
+def resolve_human_input_node_id(*, node_id: str, form_id: str, variable_pool: ReadOnlyVariablePool | None) -> str:
+ """Project form ownership onto the visible graph without changing engine identity."""
+ container = variable_pool.get(human_input_container_selector(form_id)) if variable_pool is not None else None
+ return container.value if isinstance(container, StringSegment) else node_id
+
+
def build_human_input_pause_reason(
*, reason: HitlRequired, record: HumanInputFormRecord, variable_pool: ReadOnlyVariablePool | None
) -> HumanInputRequired:
"""Format a materialized form without reading its repository."""
+ form_id = record.form_id
definition = record.definition
return HumanInputRequired(
form_id=record.form_id,
form_content=record.rendered_content,
inputs=resolve_variable_select_input_options(definition.inputs, variable_pool=variable_pool),
actions=list(definition.user_actions),
- node_id=reason.node_id,
+ node_id=resolve_human_input_node_id(node_id=reason.node_id, form_id=form_id, variable_pool=variable_pool),
node_title=reason.node_title or definition.node_title or record.node_id,
resolved_default_values=dict(definition.default_values),
)
diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py
index b510f86136b6d8..84dc399dc85836 100644
--- a/api/core/workflow/workflow_entry.py
+++ b/api/core/workflow/workflow_entry.py
@@ -1,8 +1,9 @@
import logging
import time
-from collections.abc import Generator, Mapping, Sequence
+from collections.abc import Callable, Generator, Mapping, Sequence
from contextlib import ExitStack
from contextvars import copy_context
+from functools import partial
from typing import TYPE_CHECKING, Any, TypedDict
from uuid import uuid4
@@ -19,6 +20,7 @@
from core.app.workflow.layers.observability import ObservabilityLayer
from core.credit_usage import CreditUsageAppType
from core.repositories.human_input_repository import HumanInputFormSubmissionRepository
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
from core.workflow.node_factory import (
DifyGraphInitContext,
DifyNodeFactory,
@@ -34,10 +36,17 @@
)
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
from core.workflow.variable_prefixes import ENVIRONMENT_VARIABLE_NODE_ID
+from core.workflow.workflow_tool_container_handler import (
+ WorkflowToolContainerHandler,
+ WorkflowToolEventListenerFactory,
+ WorkflowToolNestedContainerHandler,
+)
from extensions.otel.runtime import is_instrument_flag_enabled
from factories import file_factory
from graphon.engine import Engine
from graphon.engine.command import CommandChannel, InMemoryChannel
+from graphon.engine.container_handler.builtin.iteration import IterationContainerHandler
+from graphon.engine.container_handler.builtin.loop import LoopContainerHandler
from graphon.engine.filter import EngineEventFilterContext, ResponseStreamFilter, filter_engine_events
from graphon.engine.layer import ExecutionLimitsLayer, Layer
from graphon.engine_events import EngineEvent, GraphRunFailedEvent, NodeEvent, is_node_result_event
@@ -138,18 +147,43 @@ def __init__(
self.command_channel = command_channel
self.graph_config = graph_config
+ self.workflow_tool_sources: dict[str, WorkflowToolSource] = {}
+ self.workflow_tool_event_listener_factory: WorkflowToolEventListenerFactory | None = None
self._response_stream_filter = response_stream_filter or ResponseStreamFilter()
file_runtime = create_dify_workflow_file_runtime()
execution_context_layer = ExecutionContextLayer()
limits_layer = ExecutionLimitsLayer(
max_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME
)
+ workflow_tool_event_listeners: dict[str, Callable[[NodeEvent], None]] = {}
self.graph_engine = Engine(
graph=graph,
runtime_state=graph_runtime_state,
command_channel=command_channel,
workers=dify_config.GRAPH_ENGINE_MAX_WORKERS,
file_runtime=file_runtime,
+ container_handler_factories=(
+ partial(
+ WorkflowToolNestedContainerHandler,
+ handler_factory=LoopContainerHandler,
+ hidden_event_listener=limits_layer.on_event,
+ event_listeners=workflow_tool_event_listeners,
+ ),
+ partial(
+ WorkflowToolNestedContainerHandler,
+ handler_factory=IterationContainerHandler,
+ hidden_event_listener=limits_layer.on_event,
+ event_listeners=workflow_tool_event_listeners,
+ ),
+ partial(
+ WorkflowToolContainerHandler,
+ sources=self.workflow_tool_sources,
+ hidden_event_listener=limits_layer.on_event,
+ event_listener_factory=self._create_workflow_tool_event_listener,
+ event_listeners=workflow_tool_event_listeners,
+ execution_context_factory=execution_context_layer.enter_context,
+ ),
+ ),
)
self.graph_engine.add_layer(execution_context_layer)
@@ -160,6 +194,13 @@ def __init__(
if dify_config.ENABLE_OTEL or is_instrument_flag_enabled():
self.graph_engine.add_layer(ObservabilityLayer())
+ def _create_workflow_tool_event_listener(
+ self, source: WorkflowToolSource, caller_workflow_id: str, caller_node_execution_id: str
+ ) -> Callable[[NodeEvent], None]:
+ if self.workflow_tool_event_listener_factory is None:
+ return lambda event: None
+ return self.workflow_tool_event_listener_factory(source, caller_workflow_id, caller_node_execution_id)
+
@property
def response_stream_filter(self) -> ResponseStreamFilter:
return self._response_stream_filter
@@ -284,6 +325,7 @@ def single_step_run(
node_factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
runtime_state=graph_runtime_state,
+ use_workflow_tool_containers=False,
)
node = node_factory.create_node(node_config)
diff --git a/api/core/workflow/workflow_tool_container_handler.py b/api/core/workflow/workflow_tool_container_handler.py
new file mode 100644
index 00000000000000..aecc24440f85a6
--- /dev/null
+++ b/api/core/workflow/workflow_tool_container_handler.py
@@ -0,0 +1,518 @@
+from __future__ import annotations
+
+import json
+import logging
+from collections.abc import Callable, Mapping, Sequence
+from contextlib import AbstractContextManager, nullcontext
+from typing import Any, final
+
+from configs import dify_config
+from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
+from core.app.apps.base_app_generator import BaseAppGenerator
+from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom
+from core.app.file_access import DatabaseFileAccessController
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
+from core.workflow.node_execution_process_data import (
+ WORKFLOW_TOOL_INVOCATION_ID_KEY,
+)
+from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory, get_default_root_node_id
+from core.workflow.node_runtime import resolve_dify_run_context
+from core.workflow.nodes.human_input.boundary import human_input_container_selector
+from core.workflow.nodes.human_input.session_binding import default_session_binding
+from core.workflow.snippet_start import get_compatible_start_aliases
+from core.workflow.system_variables import (
+ SystemVariableKey,
+ get_all_system_variables,
+ system_variable_selector,
+)
+from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
+from factories import file_factory
+from graphon.engine.container_handler.protocol import ContainerHandler
+from graphon.engine.frame import ExecutionFrame, FrameRegistry
+from graphon.engine.ready_queue import ResumeTask
+from graphon.engine_events.base import NodeEvent
+from graphon.engine_events.node import NodeRunFailedEvent, NodeRunPauseRequestedEvent
+from graphon.entities.pause_reason import HitlRequired
+from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
+from graphon.file import File
+from graphon.graph import Graph
+from graphon.nodes.container_effects import (
+ ContainerAwaitRequest,
+ ContainerExecutionResult,
+ ContainerNodeRunResult,
+ ContainerValue,
+ CustomContainerRequest,
+ build_container_value,
+)
+from graphon.nodes.start.entities import StartNodeData
+from graphon.runtime import RuntimeState, VariablePool
+from graphon.runtime.container_state import (
+ ContainerFrameState,
+ CustomContainerFrameState,
+ CustomContainerRunState,
+ FrameRuntimeData,
+)
+from graphon.runtime.execution import ROOT_FRAME_ID
+from graphon.variables.segments import ArrayFileSegment
+from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter
+
+from .workflow_tool_container_types import WorkflowToolContainerPayload
+
+logger = logging.getLogger(__name__)
+
+_file_access_controller = DatabaseFileAccessController()
+_RESERVED_TOOL_OUTPUTS = frozenset(("text", "json", "files"))
+_FAILURE_SELECTOR_PREFIX = "__workflow_tool_container__"
+_HIDDEN_CHILD_EVENT_KEY = "__dify_workflow_tool_child__"
+_CONTAINER_METADATA_KEYS = frozenset(
+ (
+ WorkflowNodeExecutionMetadataKey.LOOP_ID,
+ WorkflowNodeExecutionMetadataKey.LOOP_INDEX,
+ WorkflowNodeExecutionMetadataKey.ITERATION_ID,
+ WorkflowNodeExecutionMetadataKey.ITERATION_INDEX,
+ )
+)
+
+type WorkflowToolEventListenerFactory = Callable[[WorkflowToolSource, str, str], Callable[[NodeEvent], None]]
+
+
+def _save_workflow_tool_event(event: NodeEvent, listeners: Mapping[str, Callable[[NodeEvent], None]]) -> None:
+ child_event_data = event.node_run_result.process_data.get(_HIDDEN_CHILD_EVENT_KEY)
+ child_frame_id = child_event_data.get("frame_id") if isinstance(child_event_data, Mapping) else None
+ if isinstance(child_frame_id, str) and (listener := listeners.get(child_frame_id)) is not None:
+ try:
+ listener(event)
+ except Exception:
+ logger.exception(
+ "Failed to persist Workflow Tool event: frame_id=%s, node_id=%s", child_frame_id, event.node_id
+ )
+
+
+@final
+class WorkflowToolNestedContainerHandler:
+ """Suppress Workflow Tool events emitted through a nested built-in container."""
+
+ def __init__(
+ self,
+ frame_registry: FrameRegistry,
+ *,
+ handler_factory: Callable[[FrameRegistry], ContainerHandler],
+ hidden_event_listener: Callable[[NodeEvent], None] | None = None,
+ event_listeners: Mapping[str, Callable[[NodeEvent], None]] | None = None,
+ ) -> None:
+ self._handler = handler_factory(frame_registry)
+ self._hidden_event_listener = hidden_event_listener
+ self._event_listeners = event_listeners if event_listeners is not None else {}
+ self.node_type = self._handler.node_type
+
+ def restore_frame(self, frame_state: ContainerFrameState) -> None:
+ self._handler.restore_frame(frame_state)
+
+ def handle_request(self, *, invocation_id: str, request: ContainerAwaitRequest) -> None:
+ self._handler.handle_request(invocation_id=invocation_id, request=request)
+
+ def prepare_frame_event(self, *, frame: ExecutionFrame, event: NodeEvent) -> None:
+ # Source control flow belongs to the Workflow Tool, even when node IDs overlap.
+ if _HIDDEN_CHILD_EVENT_KEY not in event.node_run_result.process_data:
+ self._handler.prepare_frame_event(frame=frame, event=event)
+
+ def should_emit(self, *, event: NodeEvent) -> bool:
+ should_emit = self._handler.should_emit(event=event)
+ if should_emit and _HIDDEN_CHILD_EVENT_KEY in event.node_run_result.process_data:
+ _save_workflow_tool_event(event, self._event_listeners)
+ if self._hidden_event_listener is not None:
+ self._hidden_event_listener(event)
+ return False
+ return should_emit
+
+ def record_frame_failure(self, *, frame: ExecutionFrame, event: NodeRunFailedEvent) -> None:
+ self._handler.record_frame_failure(frame=frame, event=event)
+
+ def complete_frame_if_ready(self, frame: ExecutionFrame) -> None:
+ self._handler.complete_frame_if_ready(frame)
+
+
+@final
+class WorkflowToolContainerHandler:
+ """Execute Workflow Tools as dynamic child frames owned by a Tool node."""
+
+ node_type = BuiltinNodeTypes.TOOL
+
+ def __init__(
+ self,
+ frame_registry: FrameRegistry,
+ *,
+ sources: Mapping[str, WorkflowToolSource],
+ hidden_event_listener: Callable[[NodeEvent], None] | None = None,
+ event_listener_factory: WorkflowToolEventListenerFactory | None = None,
+ event_listeners: dict[str, Callable[[NodeEvent], None]] | None = None,
+ execution_context_factory: Callable[[], AbstractContextManager[object]] = nullcontext,
+ ) -> None:
+ self._frame_registry = frame_registry
+ self._sources = sources
+ self._hidden_event_listener = hidden_event_listener
+ self._event_listener_factory = event_listener_factory
+ self._event_listeners = event_listeners if event_listeners is not None else {}
+ self._execution_context_factory = execution_context_factory
+
+ def restore_frame(self, frame_state: ContainerFrameState) -> None:
+ if not isinstance(frame_state, CustomContainerFrameState):
+ raise TypeError(f"Workflow Tool handler cannot restore {frame_state.kind} frame")
+ variable_pool = frame_state.runtime_data.variable_pool
+ if isinstance(variable_pool, str):
+ raise TypeError(f"Workflow Tool frame {frame_state.frame_id} requires a local variable pool")
+
+ run_state = self._get_tool_run_state(frame_state.parent_invocation_id)
+ payload = WorkflowToolContainerPayload.model_validate_json(run_state.payload)
+ with self._execution_context_factory():
+ self._create_frame(
+ run_state=run_state,
+ payload=payload,
+ frame_id=frame_state.frame_id,
+ runtime_data=frame_state.runtime_data,
+ variable_pool=variable_pool.model_copy(deep=True),
+ )
+
+ def handle_request(
+ self,
+ *,
+ invocation_id: str,
+ request: ContainerAwaitRequest,
+ ) -> None:
+ if not isinstance(request, CustomContainerRequest):
+ raise TypeError(f"Workflow Tool handler cannot handle {type(request).__name__}")
+
+ run_state = self._get_tool_run_state(invocation_id)
+ payload = WorkflowToolContainerPayload.model_validate_json(request.payload)
+ frame_id = f"{invocation_id}:workflow-tool"
+ try:
+ with self._execution_context_factory():
+ child_frame = self._create_frame(
+ run_state=run_state,
+ payload=payload,
+ frame_id=frame_id,
+ )
+ except Exception as error:
+ self._root_runtime_state().enqueue_ready_task(
+ ResumeTask(
+ invocation_id=invocation_id,
+ result=ContainerExecutionResult(
+ metadata={},
+ steps=0,
+ node_run_result=ContainerNodeRunResult(
+ status=WorkflowNodeExecutionStatus.FAILED,
+ inputs={key: build_container_value(value) for key, value in payload.inputs_for_log.items()},
+ error=str(error),
+ error_type=type(error).__name__,
+ ),
+ ),
+ )
+ )
+ return
+ self._root_runtime_state().put_container_frame(
+ CustomContainerFrameState(
+ frame_id=frame_id,
+ parent_invocation_id=invocation_id,
+ runtime_data=child_frame.state.snapshot_frame(copy_variable_pool=False),
+ )
+ )
+ child_frame.scheduler.enqueue_node(child_frame.graph.root_node.id)
+
+ def prepare_frame_event(self, *, frame: ExecutionFrame, event: NodeEvent) -> None:
+ child_event_data = event.node_run_result.process_data.get(_HIDDEN_CHILD_EVENT_KEY)
+ is_direct_workflow_tool_child = child_event_data is None
+ if is_direct_workflow_tool_child and isinstance(event, NodeRunFailedEvent):
+ # Graphon increments immediately after container preparation; only the outer Tool failure belongs here.
+ frame.state.graph_execution.exceptions_count -= 1
+ if isinstance(event, NodeRunPauseRequestedEvent) and isinstance(event.reason, HitlRequired):
+ # Keep source identity in Graphon; persist form ownership for Dify's response boundary.
+ form_id = default_session_binding.resolve_form_id_from_session_id(session_id=event.reason.session_id)
+ self._root_runtime_state().variable_pool.add(human_input_container_selector(form_id), frame.container_id)
+ # Preserve source ownership before outer containers add their own metadata.
+ event.node_run_result.process_data = {
+ **event.node_run_result.process_data,
+ _HIDDEN_CHILD_EVENT_KEY: child_event_data
+ or {
+ "frame_id": frame.frame_id,
+ "container_metadata": {
+ key: value
+ for key, value in event.node_run_result.metadata.items()
+ if key in _CONTAINER_METADATA_KEYS
+ },
+ },
+ }
+
+ def should_emit(self, *, event: NodeEvent) -> bool:
+ _save_workflow_tool_event(event, self._event_listeners)
+ if self._hidden_event_listener is not None:
+ self._hidden_event_listener(event)
+ return False
+
+ def record_frame_failure(self, *, frame: ExecutionFrame, event: NodeRunFailedEvent) -> None:
+ # ponytail: Graphon custom frames have no metadata slot; scope the pool key to this invocation.
+ frame.state.variable_pool.add(
+ self._failure_selector(frame.frame_id),
+ [event.error, event.node_run_result.error_type],
+ )
+
+ def complete_frame_if_ready(self, frame: ExecutionFrame) -> None:
+ if not frame.scheduler.is_execution_complete():
+ return
+
+ frame_state = self._get_tool_frame_state(frame.frame_id)
+ run_state = self._get_tool_run_state(frame_state.parent_invocation_id)
+ payload = WorkflowToolContainerPayload.model_validate_json(run_state.payload)
+ parent_frame = self._frame_registry[run_state.frame_id]
+ failure_variable = frame.state.variable_pool.get(self._failure_selector(frame.frame_id))
+ error = error_type = ""
+ if failure_variable is not None:
+ failure = failure_variable.to_object()
+ if (
+ not isinstance(failure, list)
+ or len(failure) != 2
+ or not isinstance(failure[0], str)
+ or not isinstance(failure[1], str | None)
+ ):
+ raise ValueError("Invalid Workflow Tool failure state")
+ error, error_type = failure
+
+ result = ContainerExecutionResult(
+ metadata={},
+ steps=frame.state.node_run_steps,
+ node_run_result=ContainerNodeRunResult(
+ status=WorkflowNodeExecutionStatus.SUCCEEDED
+ if failure_variable is None
+ else WorkflowNodeExecutionStatus.FAILED,
+ inputs={key: build_container_value(value) for key, value in payload.inputs_for_log.items()},
+ outputs=self._build_tool_outputs(frame.state.outputs) if failure_variable is None else {},
+ error=error,
+ error_type=error_type,
+ llm_usage=frame.state.llm_usage,
+ ),
+ )
+
+ parent_frame.state.enqueue_ready_task(ResumeTask(invocation_id=run_state.invocation_id, result=result))
+ self._root_runtime_state().pop_container_frame(frame.frame_id)
+ self._frame_registry.remove(frame.frame_id)
+ self._event_listeners.pop(frame.frame_id, None)
+
+ def _create_frame(
+ self,
+ *,
+ run_state: CustomContainerRunState,
+ payload: WorkflowToolContainerPayload,
+ frame_id: str,
+ runtime_data: FrameRuntimeData | None = None,
+ variable_pool: VariablePool | None = None,
+ ) -> ExecutionFrame:
+ if payload.call_depth > dify_config.WORKFLOW_CALL_MAX_DEPTH:
+ raise ValueError(f"Max workflow call depth {dify_config.WORKFLOW_CALL_MAX_DEPTH} reached.")
+
+ parent_frame = self._frame_registry[run_state.frame_id]
+ parent_node = parent_frame.graph.nodes[run_state.node_id]
+ run_context = resolve_dify_run_context(parent_node.run_context)
+ source = self._sources.get(payload.source_workflow_id)
+ if source is None or source.app_id != payload.source_app_id:
+ raise ValueError("Workflow Tool source was not found")
+ graph_config = source.graph_config
+ root_node_id = get_default_root_node_id(graph_config)
+
+ if variable_pool is None:
+ variable_pool = self._build_variable_pool(
+ parent_frame=parent_frame,
+ source=source,
+ root_node_id=root_node_id,
+ payload=payload,
+ run_context=run_context,
+ )
+ state = self._build_runtime_state(
+ parent_frame=parent_frame,
+ variable_pool=variable_pool,
+ runtime_data=runtime_data,
+ )
+ source_run_context = dict(parent_node.run_context)
+ source_run_context[DIFY_RUN_CONTEXT_KEY] = run_context.model_copy(
+ update={"app_id": source.app_id, "workflow_tool_invocation_id": run_state.invocation_id}
+ )
+ parent_factory = parent_frame.graph.node_factory
+ graph_init_context = DifyGraphInitContext(
+ workflow_id=source.workflow_id,
+ graph_config=graph_config,
+ run_context=source_run_context,
+ call_depth=payload.call_depth,
+ execution_driver=parent_factory.execution_driver if isinstance(parent_factory, DifyNodeFactory) else None,
+ )
+ human_input_run_context = (
+ parent_factory.human_input_run_context
+ if isinstance(parent_factory, DifyNodeFactory)
+ else parent_node.run_context
+ )
+ node_factory = DifyNodeFactory.from_graph_init_context(
+ graph_init_context=graph_init_context,
+ runtime_state=state,
+ human_input_run_context=human_input_run_context,
+ workflow_tools=parent_factory.workflow_tools if isinstance(parent_factory, DifyNodeFactory) else None,
+ )
+ graph = Graph.init(
+ graph_config=graph_config,
+ node_factory=node_factory,
+ root_node_id=root_node_id,
+ )
+ if self._event_listener_factory is not None:
+ parent_execution_id = parent_frame.state.graph_execution.get_or_create_node_execution(
+ frame_id=run_state.frame_id,
+ node_id=run_state.node_id,
+ ).execution_id
+ listener = self._event_listener_factory(source, parent_node.workflow_id, parent_execution_id)
+
+ def save_child_event(event: NodeEvent) -> None:
+ # Delivery runs after Graphon normalizes results and suppresses retry starts.
+ event_to_save = event.model_copy(deep=True)
+ child_event_data = event_to_save.node_run_result.process_data[_HIDDEN_CHILD_EVENT_KEY]
+ event_to_save.node_run_result.metadata = {
+ key: value
+ for key, value in event_to_save.node_run_result.metadata.items()
+ if key not in _CONTAINER_METADATA_KEYS
+ } | child_event_data["container_metadata"]
+ event_to_save.node_run_result.process_data = {
+ key: value
+ for key, value in event_to_save.node_run_result.process_data.items()
+ if key != _HIDDEN_CHILD_EVENT_KEY
+ } | {
+ WORKFLOW_TOOL_INVOCATION_ID_KEY: run_state.invocation_id,
+ }
+ if event_to_save.container_id == run_state.node_id:
+ event_to_save.container_id = ""
+ listener(event_to_save)
+
+ self._event_listeners[frame_id] = save_child_event
+ return self._frame_registry.create(
+ frame_id=frame_id,
+ container_id=run_state.node_id,
+ graph=graph,
+ state=state,
+ )
+
+ @staticmethod
+ def _build_runtime_state(
+ *,
+ parent_frame: ExecutionFrame,
+ variable_pool: VariablePool,
+ runtime_data: FrameRuntimeData | None,
+ ) -> RuntimeState:
+ state = RuntimeState(
+ variable_pool=variable_pool,
+ start_at=parent_frame.state.start_at,
+ llm_usage=None if runtime_data is None else runtime_data.llm_usage,
+ outputs=None if runtime_data is None else dict(runtime_data.outputs),
+ node_run_steps=0 if runtime_data is None else runtime_data.node_run_steps,
+ ready_queue=parent_frame.state.ready_queue,
+ deferred_ready_queue=parent_frame.state.deferred_ready_queue,
+ graph_execution=parent_frame.state.graph_execution,
+ )
+ if runtime_data is not None:
+ state.restore_graph_state(
+ node_states=runtime_data.graph_node_states,
+ edge_states=runtime_data.graph_edge_states,
+ )
+ return state
+
+ def _build_variable_pool(
+ self,
+ *,
+ parent_frame: ExecutionFrame,
+ source: WorkflowToolSource,
+ root_node_id: str,
+ payload: WorkflowToolContainerPayload,
+ run_context: DifyRunContext,
+ ) -> VariablePool:
+ variable_pool = VariablePool()
+ for name, value in get_all_system_variables(parent_frame.state.variable_pool).items():
+ variable_pool.add(system_variable_selector(name), value)
+ add_variables_to_pool(variable_pool, source.environment_variables)
+
+ system_files = file_factory.build_from_mappings(
+ mappings=payload.system_files,
+ tenant_id=run_context.tenant_id,
+ config=FileUploadConfigManager.convert(source.features_dict, is_vision=False),
+ strict_type_validation=run_context.invoke_from == InvokeFrom.SERVICE_API,
+ access_controller=_file_access_controller,
+ )
+ variable_pool.add(system_variable_selector(SystemVariableKey.APP_ID), source.app_id)
+ variable_pool.add(system_variable_selector(SystemVariableKey.WORKFLOW_ID), source.workflow_id)
+ variable_pool.add(system_variable_selector(SystemVariableKey.FILES), list(system_files))
+
+ root_config = next(
+ (
+ node
+ for node in source.graph_config.get("nodes", [])
+ if isinstance(node, Mapping) and node.get("id") == root_node_id
+ ),
+ None,
+ )
+ if not isinstance(root_config, Mapping) or not isinstance(root_config.get("data"), Mapping):
+ raise ValueError(f"Workflow Tool root node {root_node_id} is unavailable")
+ root_data = StartNodeData.model_validate(root_config["data"])
+ inputs = BaseAppGenerator()._prepare_user_inputs(
+ user_inputs=payload.inputs,
+ variables=root_data.variables,
+ tenant_id=run_context.tenant_id,
+ strict_type_validation=run_context.invoke_from == InvokeFrom.SERVICE_API,
+ )
+ add_node_inputs_to_pool(
+ variable_pool,
+ node_id=root_node_id,
+ inputs=inputs,
+ aliases=get_compatible_start_aliases(
+ workflow_kind=source.workflow_kind,
+ root_node_id=root_node_id,
+ ),
+ )
+ return variable_pool
+
+ @staticmethod
+ def _build_tool_outputs(workflow_outputs: Mapping[str, object]) -> dict[str, ContainerValue]:
+ json_outputs = WorkflowRuntimeTypeConverter().to_json_encodable(workflow_outputs) or {}
+ tool_outputs: dict[str, Any] = {
+ **{key: value for key, value in workflow_outputs.items() if key not in _RESERVED_TOOL_OUTPUTS},
+ "text": json.dumps(json_outputs, ensure_ascii=False),
+ "files": ArrayFileSegment(value=WorkflowToolContainerHandler._collect_files(workflow_outputs)),
+ "json": [json_outputs],
+ }
+ return {key: build_container_value(value) for key, value in tool_outputs.items()}
+
+ @staticmethod
+ def _collect_files(values: Mapping[str, object]) -> list[File]:
+ files: list[File] = []
+
+ def collect_files(value: object) -> None:
+ if isinstance(value, File):
+ files.append(value)
+ elif isinstance(value, Mapping):
+ for item in value.values():
+ collect_files(item)
+ elif isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
+ for item in value:
+ collect_files(item)
+
+ collect_files(values)
+ return files
+
+ def _root_runtime_state(self) -> RuntimeState:
+ return self._frame_registry[ROOT_FRAME_ID].state
+
+ @staticmethod
+ def _failure_selector(frame_id: str) -> tuple[str, str]:
+ return (f"{_FAILURE_SELECTOR_PREFIX}:{frame_id}", "failure")
+
+ def _get_tool_run_state(self, invocation_id: str) -> CustomContainerRunState:
+ run_state = self._root_runtime_state().get_container_run(invocation_id)
+ if not isinstance(run_state, CustomContainerRunState):
+ raise TypeError(f"Workflow Tool handler cannot use {run_state.kind} run")
+ return run_state
+
+ def _get_tool_frame_state(self, frame_id: str) -> CustomContainerFrameState:
+ frame_state = self._root_runtime_state().get_container_frame(frame_id)
+ if not isinstance(frame_state, CustomContainerFrameState):
+ raise TypeError(f"Workflow Tool handler cannot use {frame_state.kind} frame")
+ return frame_state
diff --git a/api/core/workflow/workflow_tool_container_types.py b/api/core/workflow/workflow_tool_container_types.py
new file mode 100644
index 00000000000000..20d15c1cae950b
--- /dev/null
+++ b/api/core/workflow/workflow_tool_container_types.py
@@ -0,0 +1,18 @@
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class WorkflowToolContainerPayload(BaseModel):
+ """Serializable inputs needed to execute one Workflow Tool child graph."""
+
+ model_config = ConfigDict(frozen=True)
+
+ version: Literal["1"] = "1"
+ source_app_id: str
+ source_workflow_id: str
+ source_workflow_version: str
+ inputs: dict[str, Any] = Field(default_factory=dict)
+ system_files: list[dict[str, Any]] = Field(default_factory=list)
+ inputs_for_log: dict[str, Any] = Field(default_factory=dict)
+ call_depth: int = Field(ge=0)
diff --git a/api/core/workflow/workflow_tool_node.py b/api/core/workflow/workflow_tool_node.py
new file mode 100644
index 00000000000000..f9a83997ec9bc4
--- /dev/null
+++ b/api/core/workflow/workflow_tool_node.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+from collections.abc import Generator, Mapping
+from typing import TYPE_CHECKING, Any, cast, override
+
+from graphon.enums import (
+ NodeExecutionType,
+ WorkflowNodeExecutionMetadataKey,
+ WorkflowNodeExecutionStatus,
+)
+from graphon.node_events.base import NodeEventPayload, NodeRunResult
+from graphon.node_events.node import StreamChunkEvent, StreamCompletedEvent
+from graphon.nodes.container_effects import (
+ ContainerAwaitRequest,
+ ContainerExecutionResult,
+ ContainerRunResult,
+ CustomContainerRequest,
+)
+from graphon.nodes.tool.exc import ToolNodeError
+from graphon.nodes.tool.tool_node import ToolNode
+from graphon.variables.segments import StringSegment
+
+if TYPE_CHECKING:
+ from .node_runtime import DifyToolNodeRuntime
+
+
+class DifyWorkflowToolNode(ToolNode):
+ """Run a Workflow Tool as an Engine-managed child graph."""
+
+ execution_type = NodeExecutionType.CONTAINER
+
+ @classmethod
+ @override
+ def version(cls) -> str:
+ return "1"
+
+ @override
+ # Graphon's ToolNode currently narrows this return type even though container
+ # subclasses use the broader Node contract.
+ def _run( # type: ignore[override] # pyrefly: ignore[bad-override]
+ self,
+ ) -> Generator[NodeEventPayload | ContainerAwaitRequest]:
+ tool_info = self._tool_info()
+ try:
+ variable_pool = None
+ if self.node_data.version != "1" or self.node_data.tool_node_version is not None:
+ variable_pool = self.runtime_state.variable_pool
+ tool_runtime = self._get_tool_runtime(
+ variable_pool=variable_pool,
+ node_execution_id=self.execution_id,
+ )
+ except ToolNodeError as error:
+ yield self._build_failure_event(
+ error=error,
+ message="Failed to get tool runtime",
+ inputs={},
+ tool_info=tool_info,
+ )
+ return
+
+ runtime_parameters = self._runtime.get_runtime_parameters(tool_runtime=tool_runtime)
+ parameters = self._generate_parameters(
+ tool_parameters=runtime_parameters,
+ variable_pool=self.runtime_state.variable_pool,
+ node_data=self.node_data,
+ )
+ parameters_for_log = self._generate_parameters(
+ tool_parameters=runtime_parameters,
+ variable_pool=self.runtime_state.variable_pool,
+ node_data=self.node_data,
+ for_log=True,
+ )
+ try:
+ container_runtime = cast("DifyToolNodeRuntime", self._runtime)
+ payload = container_runtime.build_workflow_tool_container_payload(
+ tool_runtime=tool_runtime,
+ tool_parameters=parameters,
+ inputs_for_log=parameters_for_log,
+ workflow_call_depth=self.workflow_call_depth,
+ )
+ except ToolNodeError as error:
+ yield self._build_failure_event(
+ error=error,
+ message="Failed to prepare Workflow Tool",
+ inputs=parameters_for_log,
+ tool_info=tool_info,
+ )
+ return
+
+ yield CustomContainerRequest(payload=payload.model_dump_json())
+
+ @override
+ def _resume_container_events(
+ self,
+ *,
+ result: ContainerRunResult,
+ ) -> Generator[NodeEventPayload | ContainerAwaitRequest]:
+ if not isinstance(result, ContainerExecutionResult):
+ raise TypeError(f"Unsupported Workflow Tool container result {type(result).__name__}")
+
+ node_run_result = result.node_run_result.to_node_run_result()
+ node_run_result.metadata = self._build_completion_metadata(
+ tool_info=self._tool_info(),
+ usage=node_run_result.llm_usage,
+ )
+ if node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED:
+ text_output = node_run_result.outputs.get("text")
+ if isinstance(text_output, StringSegment) and text_output.value:
+ yield StreamChunkEvent(
+ selector=[self._node_id, "text"],
+ chunk=text_output.value,
+ is_final=False,
+ )
+ yield StreamChunkEvent(
+ selector=[self._node_id, "text"],
+ chunk="",
+ is_final=True,
+ )
+ yield StreamCompletedEvent(node_run_result=node_run_result)
+
+ def _tool_info(self) -> dict[str, Any]:
+ return {
+ "provider_type": self.node_data.provider_type.value,
+ "provider_id": self.node_data.provider_id,
+ "plugin_unique_identifier": self.node_data.plugin_unique_identifier,
+ }
+
+ @staticmethod
+ def _build_failure_event(
+ *,
+ error: Exception,
+ message: str,
+ inputs: Mapping[str, Any],
+ tool_info: Mapping[str, Any],
+ ) -> StreamCompletedEvent:
+ return StreamCompletedEvent(
+ node_run_result=NodeRunResult(
+ status=WorkflowNodeExecutionStatus.FAILED,
+ inputs=inputs,
+ metadata={WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info},
+ error=f"{message}: {error!s}",
+ error_type=type(error).__name__,
+ )
+ )
diff --git a/api/extensions/logstore/repositories/logstore_api_workflow_node_execution_repository.py b/api/extensions/logstore/repositories/logstore_api_workflow_node_execution_repository.py
index 3371bccb5a2bef..cf3d7577de126d 100644
--- a/api/extensions/logstore/repositories/logstore_api_workflow_node_execution_repository.py
+++ b/api/extensions/logstore/repositories/logstore_api_workflow_node_execution_repository.py
@@ -20,7 +20,13 @@
from libs.datetime_utils import ensure_naive_utc, naive_utc_now
from models.enums import CreatorUserRole
from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
-from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowNodeExecutionRepository
+from repositories.api_workflow_node_execution_repository import (
+ DifyAPIWorkflowNodeExecutionRepository,
+ WorkflowNodeExecutionSnapshot,
+)
+from repositories.sqlalchemy_api_workflow_node_execution_repository import (
+ DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
+)
from repositories.workflow_run_control import apply_workflow_stop_to_node, get_stopped_workflow_run
logger = logging.getLogger(__name__)
@@ -50,6 +56,8 @@ def _dict_to_workflow_node_execution_model(data: dict[str, Any]) -> WorkflowNode
model.tenant_id = data.get("tenant_id") or ""
model.app_id = data.get("app_id") or ""
model.workflow_id = data.get("workflow_id") or ""
+ model.triggered_from_workflow_id = data.get("triggered_from_workflow_id") or None
+ model.triggered_from_node_execution_id = data.get("triggered_from_node_execution_id") or None
triggered_from_val = data.get("triggered_from")
try:
model.triggered_from = (
@@ -128,7 +136,7 @@ def __init__(self, session_maker: sessionmaker | None = None):
Initialize the repository with LogStore client.
Args:
- session_maker: SQLAlchemy sessionmaker (unused, for compatibility with factory pattern)
+ session_maker: SQLAlchemy sessionmaker for synchronous Agent caller records.
"""
logger.debug("LogstoreAPIWorkflowNodeExecutionRepository.__init__: initializing")
self.logstore_client = AliyunLogStore()
@@ -144,6 +152,15 @@ def _apply_stopped_run(
for execution in executions:
apply_workflow_stop_to_node(execution, stopped)
+ @override
+ def delete_executions_by_app(self, tenant_id: str, app_id: str, batch_size: int = 1000) -> int:
+ """Delete SQL caller records; append-only Logstore traces retain their configured TTL."""
+ if self._session_maker is None:
+ raise ValueError("session_maker is required to delete SQL workflow caller records")
+ return DifyAPISQLAlchemyWorkflowNodeExecutionRepository(self._session_maker).delete_executions_by_app(
+ tenant_id=tenant_id, app_id=app_id, batch_size=batch_size
+ )
+
@override
def load_full_process_data(self, execution: WorkflowNodeExecutionModel) -> Mapping[str, Any] | None:
"""Return complete Process Data already stored inline by LogStore."""
@@ -157,206 +174,107 @@ def get_node_last_execution(
workflow_id: str,
node_id: str,
) -> WorkflowNodeExecutionModel | None:
- """
- Get the most recent execution for a specific node.
-
- Uses query syntax to get raw logs and selects the one with max log_version.
- Returns the most recent execution ordered by created_at.
- """
- logger.debug(
- "get_node_last_execution: tenant_id=%s, app_id=%s, workflow_id=%s, node_id=%s",
- tenant_id,
- app_id,
- workflow_id,
- node_id,
+ """Return the most recent visible, non-paused execution after selecting each latest version."""
+ filter_values = {"tenant_id": tenant_id, "app_id": app_id, "workflow_id": workflow_id, "node_id": node_id}
+ sql_filters = " AND ".join(f"{key} = '{escape_identifier(value)}'" for key, value in filter_values.items())
+ search_query = " and ".join(
+ f"{key}: {escape_logstore_query_value(value)}" for key, value in filter_values.items()
)
- try:
- # Escape parameters to prevent SQL injection
- escaped_tenant_id = escape_identifier(tenant_id)
- escaped_app_id = escape_identifier(app_id)
- escaped_workflow_id = escape_identifier(workflow_id)
- escaped_node_id = escape_identifier(node_id)
-
- # Check if PG protocol is supported
- if self.logstore_client.supports_pg_protocol:
- # Use PG protocol with SQL query (get latest version of each record)
- sql_query = f"""
- SELECT * FROM (
- SELECT *,
- ROW_NUMBER() OVER (PARTITION BY id ORDER BY log_version DESC) as rn
- FROM "{AliyunLogStore.workflow_node_execution_logstore}"
- WHERE tenant_id = '{escaped_tenant_id}'
- AND app_id = '{escaped_app_id}'
- AND workflow_id = '{escaped_workflow_id}'
- AND node_id = '{escaped_node_id}'
- AND __time__ > 0
- ) AS subquery WHERE rn = 1
- LIMIT 100
- """
- results = self.logstore_client.execute_sql(
- sql=sql_query,
- logstore=AliyunLogStore.workflow_node_execution_logstore,
- )
- else:
- # Use SDK with LogStore query syntax
- query = (
- f"tenant_id: {escaped_tenant_id} and app_id: {escaped_app_id} "
- f"and workflow_id: {escaped_workflow_id} and node_id: {escaped_node_id}"
- )
- from_time = 0
- to_time = int(time.time()) # now
-
- results = self.logstore_client.get_logs(
- logstore=AliyunLogStore.workflow_node_execution_logstore,
- from_time=from_time,
- to_time=to_time,
- query=query,
- line=100,
- reverse=False,
- )
-
- if not results:
- return None
-
- # For SDK mode, group by id and select the one with max log_version for each group
- # For PG mode, this is already done by the SQL query
- if not self.logstore_client.supports_pg_protocol:
- id_to_results: dict[str, list[dict[str, Any]]] = {}
- for row in results:
- row_id = row.get("id")
- if row_id:
- if row_id not in id_to_results:
- id_to_results[row_id] = []
- id_to_results[row_id].append(row)
+ results = self.logstore_client.execute_sql(
+ sql=f"""
+ SELECT * FROM (
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY id ORDER BY log_version DESC) AS rn
+ FROM "{AliyunLogStore.workflow_node_execution_logstore}"
+ WHERE {sql_filters} AND __time__ > 0
+ ) AS executions WHERE rn = 1
+ AND (triggered_from IS NULL
+ OR triggered_from != '{WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL.value}')
+ AND (status IS NULL OR status != '{WorkflowNodeExecutionStatus.PAUSED.value}')
+ ORDER BY created_at DESC LIMIT 1
+ """,
+ logstore=AliyunLogStore.workflow_node_execution_logstore,
+ query=search_query,
+ )
+ return _dict_to_workflow_node_execution_model(results[0]) if results else None
- # For each id, select the row with max log_version
- deduplicated_results = []
- for rows in id_to_results.values():
- max_row = max(rows, key=lambda x: int(x.get("log_version", 0))) if len(rows) > 1 else rows[0]
- deduplicated_results.append(max_row)
- else:
- # For PG mode, results are already deduplicated by the SQL query
- deduplicated_results = results
+ @override
+ def get_executions_by_workflow_run(
+ self, tenant_id: str, app_id: str, workflow_run_id: str
+ ) -> Sequence[WorkflowNodeExecutionModel]:
+ """Return visible terminal trace records, newest index first."""
+ executions = self._get_run_executions(tenant_id, app_id, workflow_run_id)
+ return sorted(
+ (execution for execution in executions if execution.status != WorkflowNodeExecutionStatus.PAUSED),
+ key=lambda execution: execution.index,
+ reverse=True,
+ )
- # Sort by created_at DESC and return the most recent one
- deduplicated_results.sort(
- key=lambda x: x.get("created_at", 0) if isinstance(x.get("created_at"), (int, float)) else 0,
- reverse=True,
+ @override
+ def get_execution_snapshots_by_workflow_run(
+ self, tenant_id: str, app_id: str, workflow_id: str, triggered_from: str, workflow_run_id: str
+ ) -> Sequence[WorkflowNodeExecutionSnapshot]:
+ return [
+ WorkflowNodeExecutionSnapshot.from_execution(execution)
+ for execution in self._get_run_executions(
+ tenant_id, app_id, workflow_run_id, workflow_id=workflow_id, triggered_from=triggered_from
)
+ ]
- for row in deduplicated_results:
- model = _dict_to_workflow_node_execution_model(row)
- if model.status != WorkflowNodeExecutionStatus.PAUSED:
- return model
-
- return None
-
- except Exception:
- logger.exception("Failed to get node last execution from LogStore")
- raise
-
- @override
- def get_executions_by_workflow_run(
+ def _get_run_executions(
self,
tenant_id: str,
app_id: str,
workflow_run_id: str,
- ) -> Sequence[WorkflowNodeExecutionModel]:
- """
- Get all node executions for a specific workflow run.
-
- Uses query syntax to get raw logs and selects the one with max log_version for each node execution.
- Ordered by index DESC for trace visualization.
- """
- logger.debug(
- "[LogStore] get_executions_by_workflow_run: tenant_id=%s, app_id=%s, workflow_run_id=%s",
- tenant_id,
- app_id,
- workflow_run_id,
+ *,
+ workflow_id: str | None = None,
+ triggered_from: str | None = None,
+ ) -> list[WorkflowNodeExecutionModel]:
+ """Read latest versions through the shared SQL/SDK adapter; snapshots omit payloads."""
+ filter_values = {"tenant_id": tenant_id, "app_id": app_id, "workflow_run_id": workflow_run_id}
+ if workflow_id is not None:
+ filter_values["workflow_id"] = workflow_id
+ sql_filters = " AND ".join(f"{key} = '{escape_identifier(value)}'" for key, value in filter_values.items())
+ search_query = " and ".join(
+ f"{key}: {escape_logstore_query_value(value)}" for key, value in filter_values.items()
)
- try:
- # Escape parameters to prevent SQL injection
- escaped_tenant_id = escape_identifier(tenant_id)
- escaped_app_id = escape_identifier(app_id)
- escaped_workflow_run_id = escape_identifier(workflow_run_id)
-
- # Check if PG protocol is supported
- if self.logstore_client.supports_pg_protocol:
- # Use PG protocol with SQL query (get latest version of each record)
- sql_query = f"""
- SELECT * FROM (
- SELECT *,
- ROW_NUMBER() OVER (PARTITION BY id ORDER BY log_version DESC) as rn
+ trigger_filter = (
+ f"triggered_from = '{escape_identifier(triggered_from)}'"
+ if triggered_from is not None
+ else f"(triggered_from IS NULL OR triggered_from != '{WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL}')"
+ )
+ columns = (
+ 'id, node_execution_id, node_id, node_type, title, "index", status, elapsed_time, '
+ "created_at, finished_at, execution_metadata"
+ if workflow_id is not None
+ else "*"
+ )
+ executions: list[WorkflowNodeExecutionModel] = []
+ offset = 0
+ to_time = int(time.time())
+ while True:
+ page = self.logstore_client.execute_sql(
+ sql=f"""
+ SELECT {columns} FROM (
+ SELECT {columns}, ROW_NUMBER() OVER (PARTITION BY id ORDER BY log_version DESC) AS rn
FROM "{AliyunLogStore.workflow_node_execution_logstore}"
- WHERE tenant_id = '{escaped_tenant_id}'
- AND app_id = '{escaped_app_id}'
- AND workflow_run_id = '{escaped_workflow_run_id}'
- AND __time__ > 0
- ) AS subquery WHERE rn = 1
- LIMIT 1000
- """
- results = self.logstore_client.execute_sql(
- sql=sql_query,
- logstore=AliyunLogStore.workflow_node_execution_logstore,
- )
- else:
- # Use SDK with LogStore query syntax
- query = (
- f"tenant_id: {escaped_tenant_id} and app_id: {escaped_app_id} "
- f"and workflow_run_id: {escaped_workflow_run_id}"
- )
- from_time = 0
- to_time = int(time.time()) # now
-
- results = self.logstore_client.get_logs(
- logstore=AliyunLogStore.workflow_node_execution_logstore,
- from_time=from_time,
- to_time=to_time,
- query=query,
- line=1000, # Get more results for node executions
- reverse=False,
- )
-
- if not results:
- return []
-
- # For SDK mode, group by id and select the one with max log_version for each group
- # For PG mode, this is already done by the SQL query
- models = []
- if not self.logstore_client.supports_pg_protocol:
- id_to_results: dict[str, list[dict[str, Any]]] = {}
- for row in results:
- row_id = row.get("id")
- if row_id:
- if row_id not in id_to_results:
- id_to_results[row_id] = []
- id_to_results[row_id].append(row)
-
- # For each id, select the row with max log_version
- for rows in id_to_results.values():
- max_row = max(rows, key=lambda x: int(x.get("log_version", 0))) if len(rows) > 1 else rows[0]
-
- model = _dict_to_workflow_node_execution_model(max_row)
- if model and model.id: # Ensure model is valid
- models.append(model)
- else:
- # For PG mode, results are already deduplicated by the SQL query
- for row in results:
- model = _dict_to_workflow_node_execution_model(row)
- if model and model.id: # Ensure model is valid
- models.append(model)
-
- self._apply_stopped_run(models, tenant_id, workflow_run_id)
- models = [model for model in models if model.status != WorkflowNodeExecutionStatus.PAUSED]
-
- # Sort by index DESC for trace visualization
- models.sort(key=lambda x: x.index, reverse=True)
-
- return models
-
- except Exception:
- logger.exception("Failed to get executions by workflow run from LogStore")
- raise
+ WHERE {sql_filters} AND {trigger_filter} AND __time__ > 0
+ ) AS executions WHERE rn = 1 ORDER BY created_at, "index", id LIMIT 1000 OFFSET {offset}
+ """,
+ logstore=AliyunLogStore.workflow_node_execution_logstore,
+ query=search_query,
+ to_time=to_time,
+ )
+ for row in page:
+ execution = _dict_to_workflow_node_execution_model(row)
+ if workflow_id is not None and row.get("elapsed_time") is None:
+ execution.elapsed_time = (
+ (execution.finished_at - execution.created_at).total_seconds() if execution.finished_at else 0.0
+ )
+ executions.append(execution)
+ if len(page) < 1000:
+ self._apply_stopped_run(executions, tenant_id, workflow_run_id)
+ return executions
+ offset += len(page)
@override
def get_execution_by_id(
@@ -364,74 +282,26 @@ def get_execution_by_id(
execution_id: str,
tenant_id: str | None = None,
) -> WorkflowNodeExecutionModel | None:
- """
- Get a workflow node execution by its ID.
- Uses query syntax to get raw logs and selects the one with max log_version.
- """
- logger.debug("get_execution_by_id: execution_id=%s, tenant_id=%s", execution_id, tenant_id)
- try:
- # Escape parameters to prevent SQL injection
- escaped_execution_id = escape_identifier(execution_id)
-
- # Check if PG protocol is supported
- if self.logstore_client.supports_pg_protocol:
- # Use PG protocol with SQL query (get latest version of record)
- if tenant_id:
- escaped_tenant_id = escape_identifier(tenant_id)
- tenant_filter = f"AND tenant_id = '{escaped_tenant_id}'"
- else:
- tenant_filter = ""
-
- sql_query = f"""
- SELECT * FROM (
- SELECT *,
- ROW_NUMBER() OVER (PARTITION BY id ORDER BY log_version DESC) as rn
- FROM "{AliyunLogStore.workflow_node_execution_logstore}"
- WHERE id = '{escaped_execution_id}' {tenant_filter} AND __time__ > 0
- ) AS subquery WHERE rn = 1
- LIMIT 1
- """
- results = self.logstore_client.execute_sql(
- sql=sql_query,
- logstore=AliyunLogStore.workflow_node_execution_logstore,
- )
- else:
- # Use SDK with LogStore query syntax
- # Note: Values must be quoted in LogStore query syntax to prevent injection
- if tenant_id:
- query = (
- f"id:{escape_logstore_query_value(execution_id)} "
- f"and tenant_id:{escape_logstore_query_value(tenant_id)}"
- )
- else:
- query = f"id:{escape_logstore_query_value(execution_id)}"
-
- from_time = 0
- to_time = int(time.time()) # now
-
- results = self.logstore_client.get_logs(
- logstore=AliyunLogStore.workflow_node_execution_logstore,
- from_time=from_time,
- to_time=to_time,
- query=query,
- line=100,
- reverse=False,
- )
-
- if not results:
- return None
-
- # For PG mode, result is already the latest version
- # For SDK mode, if multiple results, select the one with max log_version
- if self.logstore_client.supports_pg_protocol or len(results) == 1:
- execution = _dict_to_workflow_node_execution_model(results[0])
- else:
- max_result = max(results, key=lambda x: int(x.get("log_version", 0)))
- execution = _dict_to_workflow_node_execution_model(max_result)
- if execution.workflow_run_id is not None:
- self._apply_stopped_run([execution], execution.tenant_id, execution.workflow_run_id)
- return execution
-
- except Exception:
- logger.exception("Failed to get execution by ID from LogStore: execution_id=%s", execution_id)
- raise
+ """Return the latest version of an execution, optionally scoped to its tenant."""
+ filter_values = {"id": execution_id}
+ if tenant_id:
+ filter_values["tenant_id"] = tenant_id
+ sql_filters = " AND ".join(f"{key} = '{escape_identifier(value)}'" for key, value in filter_values.items())
+ search_query = " and ".join(
+ f"{key}: {escape_logstore_query_value(value)}" for key, value in filter_values.items()
+ )
+ results = self.logstore_client.execute_sql(
+ sql=f"""
+ SELECT * FROM "{AliyunLogStore.workflow_node_execution_logstore}"
+ WHERE {sql_filters} AND __time__ > 0
+ ORDER BY log_version DESC LIMIT 1
+ """,
+ logstore=AliyunLogStore.workflow_node_execution_logstore,
+ query=search_query,
+ )
+ if not results:
+ return None
+ execution = _dict_to_workflow_node_execution_model(results[0])
+ if execution.workflow_run_id is not None:
+ self._apply_stopped_run([execution], execution.tenant_id, execution.workflow_run_id)
+ return execution
diff --git a/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py b/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py
index 6bffc31f615734..714cc694a750c2 100644
--- a/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py
+++ b/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py
@@ -19,6 +19,7 @@
from core.ops.utils import JSON_DICT_ADAPTER
from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
from core.repositories.factory import OrderConfig, WorkflowNodeExecutionRepository
+from core.workflow.node_execution import WorkflowNodeExecution as DifyWorkflowNodeExecution
from extensions.logstore.aliyun_logstore import AliyunLogStore
from extensions.logstore.repositories import safe_float, safe_int
from extensions.logstore.sql_escape import escape_identifier
@@ -70,10 +71,12 @@ def _dict_to_workflow_node_execution(data: dict[str, Any]) -> WorkflowNodeExecut
created_at = datetime.fromisoformat(data.get("created_at", "")) if data.get("created_at") else datetime.now()
finished_at = datetime.fromisoformat(data.get("finished_at", "")) if data.get("finished_at") else None
- return WorkflowNodeExecution(
+ return DifyWorkflowNodeExecution(
id=data.get("id", ""),
node_execution_id=data.get("node_execution_id"),
workflow_id=data.get("workflow_id", ""),
+ triggered_from_workflow_id=data.get("triggered_from_workflow_id") or None,
+ triggered_from_node_execution_id=data.get("triggered_from_node_execution_id") or None,
workflow_execution_id=data.get("workflow_run_id"),
index=safe_int(data.get("index", 0)),
predecessor_node_id=data.get("predecessor_node_id"),
@@ -140,6 +143,7 @@ def __init__(
# Extract user context
self._triggered_from = triggered_from
self._creator_user_id = user.id
+ self._user = user
self._session_factory = (
sessionmaker(bind=session_factory) if isinstance(session_factory, Engine) else session_factory
)
@@ -160,6 +164,16 @@ def __init__(
# and tests share the same validated source.
self._enable_dual_write = dify_config.LOGSTORE_DUAL_WRITE_ENABLED
+ @override
+ def for_workflow_tool(self, app_id: str) -> "LogstoreWorkflowNodeExecutionRepository":
+ return LogstoreWorkflowNodeExecutionRepository(
+ session_factory=self._session_factory,
+ tenant_id=self._tenant_id,
+ user=self._user,
+ app_id=app_id,
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ )
+
def _to_logstore_model(self, domain_model: WorkflowNodeExecution, *, log_version: int) -> Sequence[tuple[str, str]]:
logger.debug(
"_to_logstore_model: id=%s, node_id=%s, status=%s",
@@ -177,6 +191,10 @@ def _to_logstore_model(self, domain_model: WorkflowNodeExecution, *, log_version
version = str(log_version)
json_converter = WorkflowRuntimeTypeConverter()
+ triggered_from_workflow_id = triggered_from_node_execution_id = None
+ if isinstance(domain_model, DifyWorkflowNodeExecution):
+ triggered_from_workflow_id = domain_model.triggered_from_workflow_id
+ triggered_from_node_execution_id = domain_model.triggered_from_node_execution_id
logstore_model = [
("id", domain_model.id),
@@ -184,6 +202,8 @@ def _to_logstore_model(self, domain_model: WorkflowNodeExecution, *, log_version
("tenant_id", self._tenant_id),
("app_id", self._app_id or ""),
("workflow_id", domain_model.workflow_id),
+ ("triggered_from_workflow_id", triggered_from_workflow_id or ""),
+ ("triggered_from_node_execution_id", triggered_from_node_execution_id or ""),
(
"triggered_from",
self._triggered_from.value if hasattr(self._triggered_from, "value") else str(self._triggered_from),
@@ -300,6 +320,24 @@ def _prepare_controlled_write(self, execution: WorkflowNodeExecution) -> tuple[W
)
return execution, version
+ @override
+ def save_many(self, executions: Sequence[WorkflowNodeExecution]) -> None:
+ """Append each record through the existing writer and batch the SQL backup."""
+ if not executions:
+ return
+ controlled_executions = []
+ for execution in executions:
+ execution, version = self._prepare_controlled_write(execution)
+ controlled_executions.append(execution)
+ self.logstore_client.put_log(
+ AliyunLogStore.workflow_node_execution_logstore, self._to_logstore_model(execution, log_version=version)
+ )
+ if self._enable_dual_write:
+ try:
+ self.sql_repository.save_many(controlled_executions)
+ except Exception:
+ logger.exception("Failed to dual-write workflow node execution batch: %s", [e.id for e in executions])
+
@override
def save_synchronously(self, execution: WorkflowNodeExecution) -> None:
"""Create the SQL caller row required by Agent v2 participant ownership."""
@@ -379,6 +417,13 @@ def get_by_workflow_execution(
# Escape parameters to prevent SQL injection
escaped_workflow_execution_id = escape_identifier(workflow_execution_id)
escaped_tenant_id = escape_identifier(self._tenant_id)
+ # Legacy root records may omit their origin; exclude only new hidden tool records.
+ tool_origin = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL.value
+ origin_filter = (
+ f"triggered_from='{tool_origin}'"
+ if self._triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ else f"(triggered_from IS NULL OR triggered_from!='{tool_origin}')"
+ )
# Build ORDER BY clause for outer query
order_clause = ""
@@ -407,6 +452,7 @@ def get_by_workflow_execution(
FROM {AliyunLogStore.workflow_node_execution_logstore}
WHERE workflow_run_id='{escaped_workflow_execution_id}'
AND tenant_id='{escaped_tenant_id}'
+ AND {origin_filter}
{app_id_filter}
) t
WHERE rn = 1
diff --git a/api/migrations/versions/2026_09_23_2200-a8c3e7f4b912_add_workflow_tool_caller_locators.py b/api/migrations/versions/2026_09_23_2200-a8c3e7f4b912_add_workflow_tool_caller_locators.py
new file mode 100644
index 00000000000000..43a00be90cc3b6
--- /dev/null
+++ b/api/migrations/versions/2026_09_23_2200-a8c3e7f4b912_add_workflow_tool_caller_locators.py
@@ -0,0 +1,30 @@
+"""Store the immediate Workflow Tool caller on node executions.
+
+Revision ID: a8c3e7f4b912
+Revises: c6f4a90d8b31
+Create Date: 2026-09-23 22:00:00
+
+Existing source categories have no Workflow Tool caller and remain null.
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+from models.types import StringUUID
+
+revision = "a8c3e7f4b912"
+down_revision = "c6f4a90d8b31"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ with op.batch_alter_table("workflow_node_executions") as batch_op:
+ batch_op.add_column(sa.Column("triggered_from_workflow_id", StringUUID(), nullable=True))
+ batch_op.add_column(sa.Column("triggered_from_node_execution_id", sa.String(255), nullable=True))
+
+
+def downgrade():
+ with op.batch_alter_table("workflow_node_executions") as batch_op:
+ batch_op.drop_column("triggered_from_node_execution_id")
+ batch_op.drop_column("triggered_from_workflow_id")
diff --git a/api/models/workflow.py b/api/models/workflow.py
index e6d411bf43c986..6cb84e1b6e618f 100644
--- a/api/models/workflow.py
+++ b/api/models/workflow.py
@@ -27,6 +27,7 @@
from core.workflow.environment_variables import load_environment_variables
from core.workflow.human_input_adapter import adapt_node_config_for_graph
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
+from core.workflow.node_execution_process_data import WORKFLOW_TOOL_ROOT_APP_ID_KEY
from core.workflow.nodes.human_input.pause_reason import (
HumanInputRequired,
)
@@ -914,6 +915,7 @@ class WorkflowNodeExecutionTriggeredFrom(StrEnum):
SINGLE_STEP = "single-step"
WORKFLOW_RUN = "workflow-run"
+ WORKFLOW_TOOL = "workflow-tool"
RAG_PIPELINE_RUN = "rag-pipeline-run"
@@ -967,6 +969,25 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
__tablename__ = "workflow_node_executions"
+ @classmethod
+ def workflow_tool_owned_by_app(cls, *, tenant_id: str, app_id: str) -> sa.ColumnElement[bool]:
+ """Match trusted Tool lifecycle ownership, including rows written before it was captured."""
+ document = sa.cast(func.nullif(cls.process_data, ""), sa.JSON().with_variant(sa.Text(), "sqlite"))
+ root_app_id = document[WORKFLOW_TOOL_ROOT_APP_ID_KEY].as_string()
+ return sa.and_(
+ cls.tenant_id == tenant_id,
+ cls.triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ sa.or_(
+ root_app_id == app_id,
+ sa.and_(
+ root_app_id.is_(None),
+ cls.workflow_run_id.in_(
+ select(WorkflowRun.id).where(WorkflowRun.tenant_id == tenant_id, WorkflowRun.app_id == app_id)
+ ),
+ ),
+ ),
+ )
+
__table_args__ = (
PrimaryKeyConstraint("id", name="workflow_node_execution_pkey"),
Index(
@@ -1006,6 +1027,8 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
EnumText(WorkflowNodeExecutionTriggeredFrom, length=255)
)
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID)
+ triggered_from_workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
+ triggered_from_node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
index: Mapped[int] = mapped_column(sa.Integer)
predecessor_node_id: Mapped[str | None] = mapped_column(String(255))
node_execution_id: Mapped[str | None] = mapped_column(String(255))
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index 85fb2b78258def..8cfbf324de79b0 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -14480,7 +14480,6 @@ Import a Skill zip package from multipart form field `file`.
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow tool created successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
|
-| 400 | Bad Request | **application/json**: [WorkflowToolHumanInputNotSupportedError](#workflowtoolhumaninputnotsupportederror)
[ErrorResponse](#errorresponse)
|
| default | Error | **application/json**: [ErrorResponse](#errorresponse)
|
### [POST] /workspaces/current/tool-provider/workflow/delete
@@ -14538,7 +14537,6 @@ Import a Skill zip package from multipart form field `file`.
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow tool updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
|
-| 400 | Bad Request | **application/json**: [WorkflowToolHumanInputNotSupportedError](#workflowtoolhumaninputnotsupportederror)
[ErrorResponse](#errorresponse)
|
| default | Error | **application/json**: [ErrorResponse](#errorresponse)
|
### [GET] /workspaces/current/tool-providers
@@ -29678,14 +29676,6 @@ Workflow run not found.
| workflow_app_id | string | | No |
| workflow_tool_id | string | | No |
-#### WorkflowToolHumanInputNotSupportedError
-
-Workflow with Human Input nodes cannot be published as a workflow tool.
-
-| Name | Type | Description | Required |
-| ---- | ---- | ----------- | -------- |
-| WorkflowToolHumanInputNotSupportedError | | Workflow with Human Input nodes cannot be published as a workflow tool. | |
-
#### WorkflowToolListQuery
| Name | Type | Description | Required |
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 4b79df8a2dcebe..e13bd2a2dcba1a 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -63,6 +63,8 @@ exclude = ["providers/vdb/__pycache__", "providers/trace/__pycache__"]
[tool.uv.sources]
dify-agent = { path = "../dify-agent", editable = true }
flask-restx = { git = "https://github.com/asukaminato0721/flask-restx", rev = "27758e26f8f740d7525d5039c51a9e524b6e2b68" }
+# shortcut: pin the 0.8.0 container-type fix until a release includes graphon#344.
+graphon = { git = "https://github.com/langgenius/graphon", rev = "4fa22141774dff80e90e4dfa98ea3e6fbf0421bb" }
dify-vdb-alibabacloud-mysql = { workspace = true }
dify-vdb-analyticdb = { workspace = true }
dify-vdb-baidu = { workspace = true }
diff --git a/api/repositories/api_workflow_node_execution_repository.py b/api/repositories/api_workflow_node_execution_repository.py
index 9f077548994ec4..8da4e9b0161a9e 100644
--- a/api/repositories/api_workflow_node_execution_repository.py
+++ b/api/repositories/api_workflow_node_execution_repository.py
@@ -9,6 +9,9 @@
tenant_id, app_id, triggered_from, etc., which are not part of the core domain model.
"""
+from __future__ import annotations
+
+import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
@@ -16,9 +19,24 @@
from sqlalchemy.orm import Session
+from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload
+class WorkflowNodeExecutionSnapshotRow(Protocol):
+ id: str
+ node_execution_id: str | None
+ node_id: str
+ node_type: str
+ title: str
+ index: int
+ status: WorkflowNodeExecutionStatus
+ elapsed_time: float | None
+ created_at: datetime
+ finished_at: datetime | None
+ execution_metadata: str | None
+
+
@dataclass(frozen=True)
class WorkflowNodeExecutionSnapshot:
"""
@@ -39,6 +57,37 @@ class WorkflowNodeExecutionSnapshot:
iteration_id: str | None = None # Iteration id from execution metadata, if any.
loop_id: str | None = None # Loop id from execution metadata, if any.
+ @classmethod
+ def from_execution(
+ cls, row: WorkflowNodeExecutionSnapshotRow | WorkflowNodeExecutionModel
+ ) -> WorkflowNodeExecutionSnapshot:
+ metadata: dict[str, object] = {}
+ if row.execution_metadata:
+ try:
+ parsed_metadata = json.loads(row.execution_metadata)
+ if isinstance(parsed_metadata, dict):
+ metadata = parsed_metadata
+ except json.JSONDecodeError:
+ metadata = {}
+ iteration_id = metadata.get(WorkflowNodeExecutionMetadataKey.ITERATION_ID.value)
+ loop_id = metadata.get(WorkflowNodeExecutionMetadataKey.LOOP_ID.value)
+ elapsed_time = row.elapsed_time
+ if elapsed_time is None:
+ elapsed_time = (row.finished_at - row.created_at).total_seconds() if row.finished_at else 0.0
+ return cls(
+ execution_id=row.node_execution_id or row.id,
+ node_id=row.node_id,
+ node_type=row.node_type,
+ title=row.title,
+ index=row.index,
+ status=row.status,
+ elapsed_time=float(elapsed_time),
+ created_at=row.created_at,
+ finished_at=row.finished_at,
+ iteration_id=str(iteration_id) if iteration_id else None,
+ loop_id=str(loop_id) if loop_id else None,
+ )
+
class DifyAPIWorkflowNodeExecutionRepository(Protocol):
"""
@@ -96,10 +145,11 @@ def get_executions_by_workflow_run(
workflow_run_id: str,
) -> Sequence[WorkflowNodeExecutionModel]:
"""
- Get all node executions for a specific workflow run.
+ Get visible node executions for a specific workflow run.
- This method retrieves all node executions that belong to a specific workflow run,
+ This method retrieves visible node executions that belong to a specific workflow run,
ordered by index in descending order for proper trace visualization.
+ Internal Workflow Tool executions are excluded even when the source is the same app and workflow.
Args:
tenant_id: The tenant identifier
diff --git a/api/repositories/app/agent_sandbox_repository.py b/api/repositories/app/agent_sandbox_repository.py
index a1e5c892b79397..b979d91484a677 100644
--- a/api/repositories/app/agent_sandbox_repository.py
+++ b/api/repositories/app/agent_sandbox_repository.py
@@ -5,6 +5,7 @@
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
+from core.workflow.node_execution_process_data import WORKFLOW_TOOL_INVOCATION_ID_KEY
from core.workflow.nodes.agent_v2.session_store import resolve_workflow_agent_workspace_owner_scope
from machinery.context import RequestContext
from models.agent import Agent, AgentConfigDraft, AgentConfigDraftType, AgentWorkspaceBinding, AgentWorkspaceOwnerType
@@ -151,10 +152,12 @@ def _resolve_workflow_binding(
)
process_data = execution.process_data_dict if execution is not None else None
workflow_agent_binding_id = process_data.get("workflow_agent_binding_id") if process_data is not None else None
+ invocation_id = process_data.get(WORKFLOW_TOOL_INVOCATION_ID_KEY) if process_data is not None else None
if (
execution is None
or execution.agent_workspace_binding_id is None
or not isinstance(workflow_agent_binding_id, str)
+ or (invocation_id is not None and not isinstance(invocation_id, str))
):
raise AgentSandboxBindingNotFoundError(
"this Workflow Agent node execution has no active Workspace Binding",
@@ -179,6 +182,7 @@ def _resolve_workflow_binding(
node_id=node_id,
workflow_agent_binding_id=workflow_agent_binding_id,
node_execution_id=node_execution_id,
+ workflow_tool_invocation_id=invocation_id,
),
)
if binding is None or binding.app_id != app_id:
diff --git a/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py b/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py
index 14c4716da6771b..26f64dce5f835d 100644
--- a/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py
+++ b/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py
@@ -5,38 +5,24 @@
using SQLAlchemy 2.0 style queries for WorkflowNodeExecutionModel operations.
"""
-import json
from collections.abc import Mapping, Sequence
from datetime import datetime
-from typing import Any, Protocol, cast, override
+from typing import Any, cast, override
-from sqlalchemy import asc, delete, desc, func, select
+from sqlalchemy import asc, delete, desc, func, or_, select
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session, sessionmaker
from extensions.ext_storage import storage
-from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
-from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload
+from graphon.enums import WorkflowNodeExecutionStatus
+from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload, WorkflowNodeExecutionTriggeredFrom
from repositories.api_workflow_node_execution_repository import (
DifyAPIWorkflowNodeExecutionRepository,
WorkflowNodeExecutionSnapshot,
+ WorkflowNodeExecutionSnapshotRow,
)
-class _WorkflowNodeExecutionSnapshotRow(Protocol):
- id: str
- node_execution_id: str | None
- node_id: str
- node_type: str
- title: str
- index: int
- status: WorkflowNodeExecutionStatus
- elapsed_time: float | None
- created_at: datetime
- finished_at: datetime | None
- execution_metadata: str | None
-
-
class DifyAPISQLAlchemyWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecutionRepository):
"""
SQLAlchemy implementation of DifyAPIWorkflowNodeExecutionRepository.
@@ -105,6 +91,7 @@ def get_node_last_execution(
WorkflowNodeExecutionModel.app_id == app_id,
WorkflowNodeExecutionModel.workflow_id == workflow_id,
WorkflowNodeExecutionModel.node_id == node_id,
+ WorkflowNodeExecutionModel.triggered_from != WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
WorkflowNodeExecutionModel.status != WorkflowNodeExecutionStatus.PAUSED,
)
.order_by(desc(WorkflowNodeExecutionModel.created_at))
@@ -140,6 +127,7 @@ def get_executions_by_workflow_run(
WorkflowNodeExecutionModel.tenant_id == tenant_id,
WorkflowNodeExecutionModel.app_id == app_id,
WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id,
+ WorkflowNodeExecutionModel.triggered_from != WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
).order_by(asc(WorkflowNodeExecutionModel.created_at))
with self._session_maker() as session:
@@ -182,43 +170,9 @@ def get_execution_snapshots_by_workflow_run(
)
with self._session_maker() as session:
- rows = cast(Sequence[_WorkflowNodeExecutionSnapshotRow], session.execute(stmt).all())
-
- return [self._row_to_snapshot(row) for row in rows]
+ rows = cast(Sequence[WorkflowNodeExecutionSnapshotRow], session.execute(stmt).all())
- @staticmethod
- def _row_to_snapshot(row: _WorkflowNodeExecutionSnapshotRow) -> WorkflowNodeExecutionSnapshot:
- metadata: dict[str, object] = {}
- execution_metadata = getattr(row, "execution_metadata", None)
- if execution_metadata:
- try:
- metadata = json.loads(execution_metadata)
- except json.JSONDecodeError:
- metadata = {}
- iteration_id = metadata.get(WorkflowNodeExecutionMetadataKey.ITERATION_ID.value)
- loop_id = metadata.get(WorkflowNodeExecutionMetadataKey.LOOP_ID.value)
- execution_id = getattr(row, "node_execution_id", None) or row.id
- elapsed_time = getattr(row, "elapsed_time", None)
- created_at = row.created_at
- finished_at = getattr(row, "finished_at", None)
- if elapsed_time is None:
- if finished_at is not None and created_at is not None:
- elapsed_time = (finished_at - created_at).total_seconds()
- else:
- elapsed_time = 0.0
- return WorkflowNodeExecutionSnapshot(
- execution_id=str(execution_id),
- node_id=row.node_id,
- node_type=row.node_type,
- title=row.title,
- index=row.index,
- status=row.status,
- elapsed_time=float(elapsed_time),
- created_at=created_at,
- finished_at=finished_at,
- iteration_id=str(iteration_id) if iteration_id else None,
- loop_id=str(loop_id) if loop_id else None,
- )
+ return [WorkflowNodeExecutionSnapshot.from_execution(row) for row in rows]
@override
def get_execution_by_id(
@@ -309,7 +263,7 @@ def delete_executions_by_app(
batch_size: int = 1000,
) -> int:
"""
- Delete all workflow node executions for a specific app.
+ Delete source-app executions and nested executions owned by the app's runs.
Args:
tenant_id: The tenant identifier
@@ -328,7 +282,10 @@ def delete_executions_by_app(
select(WorkflowNodeExecutionModel.id)
.where(
WorkflowNodeExecutionModel.tenant_id == tenant_id,
- WorkflowNodeExecutionModel.app_id == app_id,
+ or_(
+ WorkflowNodeExecutionModel.app_id == app_id,
+ WorkflowNodeExecutionModel.workflow_tool_owned_by_app(tenant_id=tenant_id, app_id=app_id),
+ ),
)
.limit(batch_size)
)
@@ -338,6 +295,12 @@ def delete_executions_by_app(
break
# Delete the batch
+ session.execute(
+ delete(WorkflowNodeExecutionOffload).where(
+ WorkflowNodeExecutionOffload.tenant_id == tenant_id,
+ WorkflowNodeExecutionOffload.node_execution_id.in_(execution_ids),
+ )
+ )
delete_stmt = delete(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.id.in_(execution_ids))
result = cast(CursorResult, session.execute(delete_stmt))
session.commit()
diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py
index 85607289134a6b..7996d362862830 100644
--- a/api/repositories/sqlalchemy_api_workflow_run_repository.py
+++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py
@@ -163,7 +163,7 @@ def _build_human_input_required_reason(
node_id = reason_model.node_id
if form_model is not None:
form_id = form_model.id
- node_id = form_model.node_id or node_id
+ node_id = node_id or form_model.node_id
try:
definition_payload = json.loads(form_model.form_definition)
if "expiration_time" not in definition_payload:
diff --git a/api/repositories/workflow_tool_source_repository.py b/api/repositories/workflow_tool_source_repository.py
new file mode 100644
index 00000000000000..cb6ee6a1993fde
--- /dev/null
+++ b/api/repositories/workflow_tool_source_repository.py
@@ -0,0 +1,86 @@
+from collections.abc import Sequence
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session, sessionmaker
+
+from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
+from core.tools.workflow_as_tool.tool import WorkflowTool
+from models.account import Account
+from models.model import App
+from models.tools import WorkflowToolProvider
+from models.workflow import Workflow
+
+
+class SQLAlchemyWorkflowToolSourceRepository:
+ def __init__(self, session_maker: sessionmaker[Session]) -> None:
+ self._session_maker = session_maker
+
+ def get_by_provider_ids(self, *, tenant_id: str, provider_ids: Sequence[str]) -> Sequence[WorkflowToolSource]:
+ if not provider_ids:
+ return ()
+ with self._session_maker() as session:
+ rows = session.execute(
+ select(Workflow, WorkflowToolProvider, Account.name)
+ .join(App, App.id == Workflow.app_id)
+ .join(
+ WorkflowToolProvider,
+ (WorkflowToolProvider.app_id == Workflow.app_id)
+ & (WorkflowToolProvider.version == Workflow.version),
+ )
+ .outerjoin(Account, Account.id == WorkflowToolProvider.user_id)
+ .where(
+ App.tenant_id == tenant_id,
+ Workflow.tenant_id == tenant_id,
+ WorkflowToolProvider.tenant_id == tenant_id,
+ WorkflowToolProvider.id.in_(provider_ids),
+ Workflow.version != Workflow.VERSION_DRAFT,
+ )
+ )
+ return tuple(
+ self._materialize(
+ workflow,
+ tool=WorkflowToolProviderController.build_tool(provider, workflow, author=author or ""),
+ )
+ for workflow, provider, author in rows
+ )
+
+ @staticmethod
+ def _materialize(workflow: Workflow, *, tool: WorkflowTool | None = None) -> WorkflowToolSource:
+ return WorkflowToolSource(
+ app_id=workflow.app_id,
+ workflow_id=workflow.id,
+ graph_config=workflow.graph_dict,
+ features_dict=workflow.features_dict,
+ environment_variables=tuple(workflow.environment_variables),
+ workflow_kind=workflow.resolved_kind,
+ tool=tool,
+ )
+
+ def get_source(
+ self,
+ *,
+ tenant_id: str,
+ app_id: str,
+ workflow_id: str,
+ version: str,
+ ) -> WorkflowToolSource | None:
+ stmt = (
+ select(Workflow)
+ .join(App, App.id == Workflow.app_id)
+ .where(
+ App.id == app_id,
+ App.tenant_id == tenant_id,
+ Workflow.id == workflow_id,
+ Workflow.tenant_id == tenant_id,
+ Workflow.app_id == app_id,
+ Workflow.version == version,
+ Workflow.version != Workflow.VERSION_DRAFT,
+ )
+ .limit(1)
+ )
+ with self._session_maker() as session:
+ workflow = session.scalar(stmt)
+ if workflow is None:
+ return None
+ return self._materialize(workflow)
diff --git a/api/services/agent/workspace_service.py b/api/services/agent/workspace_service.py
index 3c9a7fc751673a..25732720cfe880 100644
--- a/api/services/agent/workspace_service.py
+++ b/api/services/agent/workspace_service.py
@@ -12,7 +12,7 @@
from dify_agent.client import Client
from dify_agent.protocol import CreateExecutionBindingRequest, DestroyExecutionBindingRequest
-from sqlalchemy import delete, select
+from sqlalchemy import ColumnElement, delete, or_, select
from sqlalchemy.orm import Session
from clients.agent_backend.factory import create_agent_backend_client
@@ -32,7 +32,9 @@
AgentWorkingResourceStatus,
AgentWorkspace,
AgentWorkspaceBinding,
+ AgentWorkspaceOwnerType,
)
+from models.workflow import WorkflowNodeExecutionModel
from repositories.agent_workspace_repository import AgentWorkspaceRepository
logger = logging.getLogger(__name__)
@@ -251,12 +253,12 @@ def retire_workspace(cls, *, session: Session, tenant_id: str, workspace_id: str
@classmethod
def retire_all_for_app(cls, *, session: Session, tenant_id: str, app_id: str) -> list[str]:
- """Retire all ACTIVE Workspaces owned by an App in the caller's transaction."""
+ """Retire active app Workspaces, including source-app participants in its Workflow runs."""
workspaces = session.scalars(
select(AgentWorkspace).where(
AgentWorkspace.tenant_id == tenant_id,
- AgentWorkspace.app_id == app_id,
+ or_(AgentWorkspace.app_id == app_id, cls._workflow_tool_owned_by_app(tenant_id, app_id)),
AgentWorkspace.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
@@ -271,6 +273,47 @@ def retire_all_for_app(cls, *, session: Session, tenant_id: str, app_id: str) ->
retired.append(workspace_id)
return retired
+ @staticmethod
+ def _workflow_tool_owned_by_app(tenant_id: str, app_id: str) -> ColumnElement[bool]:
+ """Follow the persisted caller and Binding, independently of optional run-log storage."""
+ return (
+ select(WorkflowNodeExecutionModel.id)
+ .join(
+ AgentWorkspaceBinding,
+ AgentWorkspaceBinding.id == WorkflowNodeExecutionModel.agent_workspace_binding_id,
+ )
+ .where(
+ WorkflowNodeExecutionModel.workflow_tool_owned_by_app(tenant_id=tenant_id, app_id=app_id),
+ WorkflowNodeExecutionModel.workflow_run_id == AgentWorkspace.owner_id,
+ WorkflowNodeExecutionModel.app_id == AgentWorkspace.app_id,
+ AgentWorkspace.owner_type == AgentWorkspaceOwnerType.WORKFLOW_RUN,
+ AgentWorkspaceBinding.tenant_id == tenant_id,
+ AgentWorkspaceBinding.app_id == AgentWorkspace.app_id,
+ AgentWorkspaceBinding.workspace_id == AgentWorkspace.id,
+ )
+ .exists()
+ )
+
+ @classmethod
+ def retire_workflow_run(cls, *, session: Session, tenant_id: str, app_id: str, workflow_run_id: str) -> list[str]:
+ """Retire run participants and include already-retired Workspaces for collection retries."""
+ workspaces = session.scalars(
+ select(AgentWorkspace).where(
+ AgentWorkspace.tenant_id == tenant_id,
+ or_(AgentWorkspace.app_id == app_id, cls._workflow_tool_owned_by_app(tenant_id, app_id)),
+ AgentWorkspace.owner_type == AgentWorkspaceOwnerType.WORKFLOW_RUN,
+ AgentWorkspace.owner_id == workflow_run_id,
+ AgentWorkspace.status.in_((AgentWorkingResourceStatus.ACTIVE, AgentWorkingResourceStatus.RETIRED)),
+ )
+ ).all()
+ retired: list[str] = []
+ for workspace in workspaces:
+ if workspace.status == AgentWorkingResourceStatus.RETIRED:
+ retired.append(workspace.id)
+ elif workspace_id := cls.retire_workspace(session=session, tenant_id=tenant_id, workspace_id=workspace.id):
+ retired.append(workspace_id)
+ return retired
+
@classmethod
def retire_all_for_conversation(
cls,
diff --git a/api/services/app_storage_cleanup_service.py b/api/services/app_storage_cleanup_service.py
index e764768fb73e1f..31a614ad853eb3 100644
--- a/api/services/app_storage_cleanup_service.py
+++ b/api/services/app_storage_cleanup_service.py
@@ -7,7 +7,7 @@
import logging
-from sqlalchemy import delete, exists, or_, select
+from sqlalchemy import and_, delete, exists, or_, select
from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.sql.selectable import Exists
@@ -20,6 +20,7 @@
from models.workflow import (
Workflow,
WorkflowDraftVariableFile,
+ WorkflowNodeExecutionModel,
WorkflowNodeExecutionOffload,
WorkflowPause,
WorkflowPauseReason,
@@ -85,6 +86,19 @@ def _cleanup_pauses(tenant_id: str, app_id: str) -> None:
def _cleanup_node_offloads(tenant_id: str, app_id: str) -> None:
+ nested_execution_ids = select(WorkflowNodeExecutionModel.id).where(
+ WorkflowNodeExecutionModel.workflow_tool_owned_by_app(tenant_id=tenant_id, app_id=app_id)
+ )
+ owned_offloads = and_(
+ WorkflowNodeExecutionOffload.tenant_id == tenant_id,
+ or_(
+ WorkflowNodeExecutionOffload.app_id == app_id,
+ and_(
+ WorkflowNodeExecutionOffload.node_execution_id.is_not(None),
+ WorkflowNodeExecutionOffload.node_execution_id.in_(nested_execution_ids),
+ ),
+ ),
+ )
while True:
with session_factory.create_session() as session:
rows = session.execute(
@@ -93,10 +107,7 @@ def _cleanup_node_offloads(tenant_id: str, app_id: str) -> None:
UploadFile,
(UploadFile.id == WorkflowNodeExecutionOffload.file_id) & (UploadFile.tenant_id == tenant_id),
)
- .where(
- WorkflowNodeExecutionOffload.tenant_id == tenant_id,
- WorkflowNodeExecutionOffload.app_id == app_id,
- )
+ .where(owned_offloads)
.limit(_BATCH_SIZE)
).all()
file_ids = [row.file_id for row in rows]
@@ -104,10 +115,7 @@ def _cleanup_node_offloads(tenant_id: str, app_id: str) -> None:
session.scalars(
select(WorkflowNodeExecutionOffload.file_id).where(
WorkflowNodeExecutionOffload.file_id.in_(file_ids),
- or_(
- WorkflowNodeExecutionOffload.tenant_id != tenant_id,
- WorkflowNodeExecutionOffload.app_id != app_id,
- ),
+ ~owned_offloads,
)
)
)
@@ -120,8 +128,7 @@ def _cleanup_node_offloads(tenant_id: str, app_id: str) -> None:
session.execute(
delete(WorkflowNodeExecutionOffload).where(
WorkflowNodeExecutionOffload.id.in_([row.id for row in rows]),
- WorkflowNodeExecutionOffload.tenant_id == tenant_id,
- WorkflowNodeExecutionOffload.app_id == app_id,
+ owned_offloads,
)
)
session.execute(
diff --git a/api/services/tools/workflow_tools_manage_service.py b/api/services/tools/workflow_tools_manage_service.py
index 66de5ef6e73bd4..029aca6a763e08 100644
--- a/api/services/tools/workflow_tools_manage_service.py
+++ b/api/services/tools/workflow_tools_manage_service.py
@@ -10,7 +10,6 @@
from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity
from core.tools.entities.tool_entities import WorkflowToolParameterConfiguration, emoji_icon_adapter
from core.tools.tool_label_manager import ToolLabelManager
-from core.tools.utils.workflow_configuration_sync import WorkflowToolConfigurationUtils
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
from core.tools.workflow_as_tool.tool import WorkflowTool
from extensions.ext_database import db
@@ -77,9 +76,6 @@ def create_workflow_tool(
if workflow is None:
raise ValueError(f"Workflow not found for app {workflow_app_id}")
- # check if workflow configuration is synced
- WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(workflow.graph_dict)
-
# create workflow tool provider
workflow_tool_provider = WorkflowToolProvider(
tenant_id=tenant_id,
@@ -190,9 +186,6 @@ def update_workflow_tool(
if workflow is None:
raise ValueError(f"Workflow not found for app {workflow_tool_provider.app_id}")
- # check if workflow configuration is synced
- WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(workflow.graph_dict)
-
with sessionmaker(db.engine).begin() as _session:
_session.add(workflow_tool_provider)
diff --git a/api/services/workflow/node_output_inspector_service.py b/api/services/workflow/node_output_inspector_service.py
index 65f2b97d938009..93d6ded71ef6cc 100644
--- a/api/services/workflow/node_output_inspector_service.py
+++ b/api/services/workflow/node_output_inspector_service.py
@@ -65,7 +65,7 @@
from graphon.file import helpers as file_helpers
from models import App
from models.agent_config_entities import DeclaredOutputConfig, DeclaredOutputType, effective_declared_outputs
-from models.workflow import WorkflowNodeExecutionModel, WorkflowRun
+from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowRun
logger = logging.getLogger(__name__)
@@ -535,7 +535,7 @@ def output_preview(
def _load_run_and_executions(
self, *, app_model: App, workflow_run_id: str, session: Session
) -> tuple[WorkflowRun, Sequence[WorkflowNodeExecutionModel]]:
- """Fetch the ``WorkflowRun`` row + every execution that belongs to it.
+ """Fetch the run and its visible nodes, excluding internal Workflow Tool executions.
Enforces:
* row exists,
@@ -560,6 +560,7 @@ def _load_run_and_executions(
WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id,
WorkflowNodeExecutionModel.tenant_id == app_model.tenant_id,
WorkflowNodeExecutionModel.app_id == app_model.id,
+ WorkflowNodeExecutionModel.triggered_from != WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
)
).all()
diff --git a/api/services/workflow_persistence.py b/api/services/workflow_persistence.py
index de40d24ad3058f..9244679933f004 100644
--- a/api/services/workflow_persistence.py
+++ b/api/services/workflow_persistence.py
@@ -4,7 +4,7 @@
and history before the engine starts. Engine events remain storage-agnostic.
"""
-from collections.abc import Mapping, Sequence
+from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any, override
@@ -15,7 +15,9 @@
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
-from core.workflow.node_execution_process_data import preserve_workflow_agent_binding_id
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
+from core.workflow.node_execution import WorkflowNodeExecution as DifyWorkflowNodeExecution
+from core.workflow.node_execution_process_data import WORKFLOW_TOOL_ROOT_APP_ID_KEY, keep_agent_and_tool_ids
from core.workflow.system_variables import SystemVariableKey
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
from core.workflow.workflow_run_outputs import project_node_outputs_for_workflow_run
@@ -27,6 +29,7 @@
GraphRunPartialSucceededEvent,
GraphRunStartedEvent,
GraphRunSucceededEvent,
+ NodeEvent,
NodeRunExceptionEvent,
NodeRunFailedEvent,
NodeRunPauseRequestedEvent,
@@ -85,6 +88,7 @@ def __init__(
self._workflow_execution: WorkflowExecution | None = None
self._node_execution_cache: dict[str, WorkflowNodeExecution] = {}
self._node_run_indices: Mapping[str, int] = {}
+ self._workflow_tool_layers: dict[tuple[str, str], WorkflowPersistenceLayer] = {}
@property
def workflow_execution(self) -> WorkflowExecution:
@@ -95,11 +99,58 @@ def set_node_run_indices(self, indices: Mapping[str, int]) -> None:
self._node_run_indices = indices
def set_node_execution_history(self, executions: Sequence[WorkflowNodeExecution]) -> None:
- """Restore materialized history before execution starts."""
+ """Restore materialized history before execution starts or a source frame is scheduled."""
self._node_execution_cache = {
execution.node_execution_id or execution.id: execution for execution in executions
}
+ def create_workflow_tool_event_listener(
+ self,
+ source: WorkflowToolSource,
+ caller_workflow_id: str,
+ caller_node_execution_id: str,
+ *,
+ node_executions: Sequence[WorkflowNodeExecution] = (),
+ ) -> Callable[[NodeEvent], None]:
+ """Persist hidden tool nodes under their source app, without creating another run."""
+ key = (source.app_id, source.workflow_id)
+ tool_layer = self._workflow_tool_layers.get(key)
+ if tool_layer is None:
+ tool_layer = WorkflowPersistenceLayer(
+ application_generate_entity=self._application_generate_entity,
+ workflow_info=PersistenceWorkflowInfo(
+ workflow_id=source.workflow_id,
+ workflow_type=WorkflowType.WORKFLOW,
+ version="",
+ graph_data=source.graph_config,
+ ),
+ workflow_execution_repository=self._workflow_execution_repository,
+ workflow_node_execution_repository=self._workflow_node_execution_repository.for_workflow_tool(
+ source.app_id
+ ),
+ )
+ tool_layer.set_node_run_indices(self._node_run_indices)
+ tool_layer.set_node_execution_history(node_executions)
+ self._workflow_tool_layers[key] = tool_layer
+
+ def on_node_event(event: NodeEvent) -> None:
+ tool_layer._workflow_execution = self._get_workflow_execution()
+ # Lifecycle ownership must survive independently of the configured run-log store.
+ event.node_run_result.process_data = {
+ **event.node_run_result.process_data,
+ WORKFLOW_TOOL_ROOT_APP_ID_KEY: self._application_generate_entity.app_config.app_id,
+ }
+ if isinstance(event, NodeRunStartedEvent) and not isinstance(event, NodeRunRetryEvent):
+ tool_layer._handle_node_started(
+ event,
+ caller_workflow_id=caller_workflow_id,
+ caller_node_execution_id=caller_node_execution_id,
+ )
+ else:
+ tool_layer.on_event(event)
+
+ return on_node_event
+
# ------------------------------------------------------------------
# Layer lifecycle
# ------------------------------------------------------------------
@@ -187,7 +238,13 @@ def _finish_workflow_execution(self, execution: WorkflowExecution) -> None:
# ------------------------------------------------------------------
# Node-level handlers
# ------------------------------------------------------------------
- def _handle_node_started(self, event: NodeRunStartedEvent) -> None:
+ def _handle_node_started(
+ self,
+ event: NodeRunStartedEvent,
+ *,
+ caller_workflow_id: str | None = None,
+ caller_node_execution_id: str | None = None,
+ ) -> None:
execution = self._get_workflow_execution()
iteration_id = event.node_run_result.metadata.get(WorkflowNodeExecutionMetadataKey.ITERATION_ID)
loop_id = event.node_run_result.metadata.get(WorkflowNodeExecutionMetadataKey.LOOP_ID)
@@ -197,11 +254,19 @@ def _handle_node_started(self, event: NodeRunStartedEvent) -> None:
WorkflowNodeExecutionMetadataKey.ITERATION_ID: iteration_id,
WorkflowNodeExecutionMetadataKey.LOOP_ID: loop_id,
}
+ if event.node_type == BuiltinNodeTypes.TOOL and event.provider_type:
+ metadata[WorkflowNodeExecutionMetadataKey.TOOL_INFO] = {
+ "provider_type": event.provider_type,
+ "provider_id": event.provider_id,
+ }
+
previous = self._node_execution_cache.get(event.id)
- domain_execution = WorkflowNodeExecution(
+ domain_execution = DifyWorkflowNodeExecution(
id=previous.id if previous is not None else event.id,
node_execution_id=event.id,
- workflow_id=execution.workflow_id,
+ workflow_id=self._workflow_info.workflow_id,
+ triggered_from_workflow_id=caller_workflow_id,
+ triggered_from_node_execution_id=caller_node_execution_id,
workflow_execution_id=execution.id_,
predecessor_node_id=event.predecessor_node_id,
index=self._node_run_indices[event.id],
@@ -209,6 +274,7 @@ def _handle_node_started(self, event: NodeRunStartedEvent) -> None:
node_type=event.node_type,
title=event.node_title,
status=WorkflowNodeExecutionStatus.RUNNING,
+ process_data=event.node_run_result.process_data or None,
metadata=metadata,
created_at=previous.created_at if previous is not None else event.start_at,
)
@@ -324,7 +390,7 @@ def _get_node_execution(self, node_execution_id: str) -> WorkflowNodeExecution:
def _append_retry_history(self, execution: WorkflowNodeExecution, event: NodeRunRetryEvent) -> None:
"""Append a validated full attempt before repository truncation or offload."""
finished_at = naive_utc_now()
- process_data = preserve_workflow_agent_binding_id(
+ process_data = keep_agent_and_tool_ids(
event.node_run_result.process_data,
execution.process_data,
)
@@ -357,7 +423,7 @@ def _merge_retry_history(
next_process_data: Mapping[str, Any] | None,
) -> Mapping[str, Any] | None:
"""Keep internal retry history while replacing node-specific Process Data."""
- merged_process_data = preserve_workflow_agent_binding_id(existing_process_data, next_process_data)
+ merged_process_data = keep_agent_and_tool_ids(existing_process_data, next_process_data)
raw_history = (existing_process_data or {}).get(RETRY_HISTORY_PROCESS_DATA_KEY)
if not isinstance(raw_history, list) or not raw_history:
return merged_process_data
@@ -407,7 +473,7 @@ def _update_node_execution(
metadata={**(domain_execution.metadata or {}), **node_result.metadata},
)
else:
- domain_execution.process_data = preserve_workflow_agent_binding_id(
+ domain_execution.process_data = keep_agent_and_tool_ids(
node_result.process_data,
domain_execution.process_data,
)
@@ -417,6 +483,8 @@ def _update_node_execution(
def persist_unfinished_nodes_synchronously(self) -> None:
"""Make paused participants durable before exposing a resumable pause."""
+ for tool_layer in self._workflow_tool_layers.values():
+ tool_layer.persist_unfinished_nodes_synchronously()
for execution in self._node_execution_cache.values():
if execution.status in (
WorkflowNodeExecutionStatus.RUNNING,
@@ -426,14 +494,18 @@ def persist_unfinished_nodes_synchronously(self) -> None:
self._workflow_node_execution_repository.save_synchronously(execution)
def _fail_running_node_executions(self, *, error_message: str) -> None:
+ for tool_layer in self._workflow_tool_layers.values():
+ tool_layer._fail_running_node_executions(error_message=error_message)
now = naive_utc_now()
+ unfinished = []
for execution in self._node_execution_cache.values():
if execution.status in (WorkflowNodeExecutionStatus.RUNNING, WorkflowNodeExecutionStatus.RETRY):
execution.status = WorkflowNodeExecutionStatus.FAILED
execution.error = error_message
execution.finished_at = now
execution.elapsed_time = max((now - execution.created_at).total_seconds(), 0.0)
- self._workflow_node_execution_repository.save(execution)
+ unfinished.append(execution)
+ self._workflow_node_execution_repository.save_many(unfinished)
def _enqueue_trace_task(self, execution: WorkflowExecution) -> None:
if not self._trace_manager:
diff --git a/api/services/workflow_run_agg.py b/api/services/workflow_run_agg.py
index c62471e5d33984..06e4ad491f262d 100644
--- a/api/services/workflow_run_agg.py
+++ b/api/services/workflow_run_agg.py
@@ -24,6 +24,8 @@
from core.db.session_factory import session_factory
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.repositories.human_input_repository import HumanInputFormRecord, HumanInputFormSubmissionRepository
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
+from core.workflow.node_factory import DifyNodeFactory
from core.workflow.node_runtime import DifyHumanInputNodeRuntime, resolve_dify_run_context
from core.workflow.nodes.agent_v2.workspace_retirement_layer import build_workflow_agent_workspace_retirement_layer
from core.workflow.nodes.human_input.boundary import build_human_input_pause_reason
@@ -31,6 +33,7 @@
from core.workflow.nodes.human_input.pause_reason import PauseReason
from core.workflow.nodes.human_input.session_binding import default_session_binding
from core.workflow.system_variables import SystemVariableKey, get_system_text
+from core.workflow.workflow_tool_container_types import WorkflowToolContainerPayload
from extensions.otel import WorkflowAppRunnerHandler, trace_span
from graphon.engine.layer import Layer
from graphon.engine_events import (
@@ -45,17 +48,24 @@
NodeRunHumanInputFormTimeoutEvent,
NodeRunStartedEvent,
)
+from graphon.entities import WorkflowNodeExecution
from graphon.entities.pause_reason import HitlRequired
from graphon.enums import WorkflowExecutionStatus
from graphon.file.runtime import use_workflow_file_runtime
+from graphon.runtime.container_state import CustomContainerRunState
from repositories.factory import DifyAPIRepositoryFactory
+from repositories.knowledge.document_repository import SQLAlchemyDocumentRepository
from repositories.sqlalchemy_api_workflow_run_repository import WorkflowPauseStoppedError
+from repositories.workflow_tool_source_repository import SQLAlchemyWorkflowToolSourceRepository
from services.conversation_variable_updater import ConversationVariableUpdater
from services.workflow_persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
from services.workflow_run_index import WorkflowRunIndex
+from services.workflow_tool_source_service import WorkflowToolSnapshot, WorkflowToolSourceService
logger = logging.getLogger(__name__)
+_WORKFLOW_TOOL_SNAPSHOT_KEY = "workflow_tool_snapshot"
+
class WorkflowRunAgg:
"""Own one execution attempt's persistence, form refresh, indexes, and durable pause."""
@@ -80,6 +90,40 @@ def __init__(
raise ValueError("Workflow run ID is required")
self._run_id = run_id
self._run_context = resolve_dify_run_context(self._entry.graph_engine.graph.root_node.run_context)
+ extras = prepared.generate_entity.extras
+ tool_snapshot = (
+ WorkflowToolSnapshot.model_validate(extras[_WORKFLOW_TOOL_SNAPSHOT_KEY])
+ if _WORKFLOW_TOOL_SNAPSHOT_KEY in extras
+ else None
+ )
+ materialized_tools = WorkflowToolSourceService(
+ SQLAlchemyWorkflowToolSourceRepository(session_factory.get_session_maker())
+ ).load(
+ tenant_id=self._run_context.tenant_id,
+ graph_config=self._entry.graph_config,
+ allow_human_input=(
+ not isinstance(prepared.generate_entity, RagPipelineGenerateEntity)
+ and (
+ prepared.generate_entity.allow_human_input
+ if isinstance(prepared.generate_entity, WorkflowAppGenerateEntity)
+ else True
+ )
+ ),
+ suspended_tools=[
+ WorkflowToolContainerPayload.model_validate_json(run.payload)
+ for run in self._runtime_state.container_runs()
+ if isinstance(run, CustomContainerRunState)
+ ],
+ snapshot=tool_snapshot,
+ )
+ self._entry.workflow_tool_sources.update(materialized_tools.sources)
+ if tool_snapshot is None:
+ # The generate entity is serialized by both durable pause/resume paths.
+ # Capture every reachable provider before any node can pause or republish it.
+ extras[_WORKFLOW_TOOL_SNAPSHOT_KEY] = materialized_tools.snapshot().model_dump(mode="json")
+ node_factory = self._entry.graph_engine.graph.node_factory
+ if isinstance(node_factory, DifyNodeFactory):
+ node_factory.workflow_tools.update(materialized_tools.tools)
self._pending_forms = {
default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id): reason
for reason in self._runtime_state.graph_execution.pause_reasons
@@ -106,11 +150,10 @@ def __init__(
workflow_node_execution_repository=workflow_node_execution_repository,
trace_manager=prepared.generate_entity.trace_manager,
)
- application_layers: list[Layer] = [self._persistence_layer]
- if not isinstance(prepared.generate_entity, RagPipelineGenerateEntity):
- application_layers.append(
- build_workflow_agent_workspace_retirement_layer(dify_run_context=self._run_context)
- )
+ application_layers: list[Layer] = [
+ self._persistence_layer,
+ build_workflow_agent_workspace_retirement_layer(dify_run_context=self._run_context),
+ ]
if isinstance(prepared.generate_entity, AdvancedChatAppGenerateEntity):
application_layers.append(
ConversationVariablePersistenceLayer(ConversationVariableUpdater(session_factory.get_session_maker()))
@@ -122,6 +165,30 @@ def __init__(
self._persistence_layer.set_node_run_indices(self.index.indices)
self._persistence_layer.set_node_execution_history(histories)
self._entry.graph_engine.add_layer(self.index)
+ source_histories: dict[tuple[str, str], Sequence[WorkflowNodeExecution]] = {}
+ for source in self._entry.workflow_tool_sources.values():
+ key = (source.app_id, source.workflow_id)
+ executions: Sequence[WorkflowNodeExecution] = ()
+ if is_resuming:
+ executions = tuple(
+ execution
+ for execution in workflow_node_execution_repository.for_workflow_tool(
+ source.app_id
+ ).get_by_workflow_execution(self._run_id, include_paused=True)
+ if execution.workflow_id == source.workflow_id
+ )
+ self.index.seed_source(source.app_id, source.workflow_id, executions)
+ source_histories[key] = executions
+
+ def source_listener(source: WorkflowToolSource, caller_workflow_id: str, caller_node_execution_id: str):
+ return self._persistence_layer.create_workflow_tool_event_listener(
+ source,
+ caller_workflow_id,
+ caller_node_execution_id,
+ node_executions=source_histories[(source.app_id, source.workflow_id)],
+ )
+
+ self._entry.workflow_tool_event_listener_factory = source_listener
@staticmethod
@trace_span(WorkflowAppRunnerHandler)
@@ -134,13 +201,24 @@ def run(
prepared = runner.prepare()
if prepared is None:
return
- aggregate = WorkflowRunAgg(
- prepared,
- runner,
- pause_state_config,
- workflow_execution_repository=workflow_execution_repository,
- workflow_node_execution_repository=workflow_node_execution_repository,
- )
+ try:
+ aggregate = WorkflowRunAgg(
+ prepared,
+ runner,
+ pause_state_config,
+ workflow_execution_repository=workflow_execution_repository,
+ workflow_node_execution_repository=workflow_node_execution_repository,
+ )
+ except ValueError as error:
+ entity = prepared.generate_entity
+ if isinstance(entity, RagPipelineGenerateEntity) and entity.document_id is not None:
+ SQLAlchemyDocumentRepository(session_factory=session_factory.get_session_maker()).mark_failed(
+ workspace_id=entity.app_config.tenant_id,
+ dataset_id=entity.dataset_id,
+ document_id=entity.document_id,
+ error=str(error),
+ )
+ raise
with closing(aggregate.iter_events()) as events:
for event in events:
runner.handle_event(
diff --git a/api/services/workflow_run_index.py b/api/services/workflow_run_index.py
index 85fbc42aa3b53b..17829b1dc1c8fd 100644
--- a/api/services/workflow_run_index.py
+++ b/api/services/workflow_run_index.py
@@ -6,6 +6,7 @@
from typing import override
from core.app.entities.queue_entities import NodeExecutionSnapshot
+from core.workflow.node_runtime import resolve_dify_run_context
from graphon.engine.layer import Layer
from graphon.entities import WorkflowNodeExecution
from graphon.enums import WorkflowNodeExecutionMetadataKey
@@ -17,11 +18,8 @@ def __init__(self, root_executions: Sequence[WorkflowNodeExecution]) -> None:
super().__init__()
self._lock = Lock()
self._indices: dict[str, int] = {}
- for execution in root_executions:
- self._indices[execution.id] = execution.index
- if execution.node_execution_id:
- self._indices[execution.node_execution_id] = execution.index
- self._next_index = max((execution.index for execution in root_executions), default=0)
+ self._next_indices: dict[tuple[str, str] | None, int] = {}
+ self._seed(None, root_executions)
self.root_snapshots = tuple(
NodeExecutionSnapshot(
execution_id=execution.node_execution_id or execution.id,
@@ -41,9 +39,24 @@ def indices(self) -> Mapping[str, int]:
def index_for(self, execution_id: str) -> int:
return self._indices[execution_id]
+ def seed_source(self, app_id: str, workflow_id: str, executions: Sequence[WorkflowNodeExecution]) -> None:
+ with self._lock:
+ self._seed((app_id, workflow_id), executions)
+
+ def _seed(self, scope: tuple[str, str] | None, executions: Sequence[WorkflowNodeExecution]) -> None:
+ if scope in self._next_indices:
+ return
+ for execution in executions:
+ self._indices[execution.id] = execution.index
+ if execution.node_execution_id:
+ self._indices[execution.node_execution_id] = execution.index
+ self._next_indices[scope] = max((execution.index for execution in executions), default=0)
+
@override
def on_node_run_start(self, node: Node) -> None:
+ context = resolve_dify_run_context(node.run_context)
+ scope = (context.app_id, node.workflow_id) if context.workflow_tool_invocation_id else None
with self._lock:
if node.execution_id not in self._indices:
- self._next_index += 1
- self._indices[node.execution_id] = self._next_index
+ self._next_indices[scope] = self._next_indices.get(scope, 0) + 1
+ self._indices[node.execution_id] = self._next_indices[scope]
diff --git a/api/services/workflow_tool_source_service.py b/api/services/workflow_tool_source_service.py
new file mode 100644
index 00000000000000..7229d98cc0ac67
--- /dev/null
+++ b/api/services/workflow_tool_source_service.py
@@ -0,0 +1,132 @@
+"""Materialize pinned Workflow Tool definitions before execution begins."""
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict
+
+from core.tools.__base.tool_runtime import ToolRuntime
+from core.tools.entities.tool_entities import ToolEntity
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
+from core.tools.workflow_as_tool.tool import WorkflowTool
+from core.workflow.workflow_tool_container_types import WorkflowToolContainerPayload
+from repositories.workflow_tool_source_repository import SQLAlchemyWorkflowToolSourceRepository
+
+
+class WorkflowToolDefinition(BaseModel):
+ """Serializable tool definition, without invocation state or credentials."""
+
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ app_id: str
+ workflow_id: str
+ version: str
+ entity: ToolEntity
+ label: str
+
+
+class WorkflowToolSnapshot(BaseModel):
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ tools: dict[str, WorkflowToolDefinition]
+
+
+@dataclass(frozen=True)
+class MaterializedWorkflowTools:
+ sources: dict[str, WorkflowToolSource]
+ # Multiple providers may refer to the same workflow with different definitions.
+ tools: dict[str, WorkflowTool]
+
+ def snapshot(self) -> WorkflowToolSnapshot:
+ return WorkflowToolSnapshot(
+ tools={
+ provider_id: WorkflowToolDefinition(
+ app_id=tool.workflow_app_id,
+ workflow_id=tool.workflow_id,
+ version=tool.version,
+ entity=tool.entity.model_copy(deep=True),
+ label=tool.label,
+ )
+ for provider_id, tool in self.tools.items()
+ }
+ )
+
+
+class WorkflowToolSourceService:
+ def __init__(self, repository: SQLAlchemyWorkflowToolSourceRepository) -> None:
+ self._repository = repository
+
+ def load(
+ self,
+ *,
+ tenant_id: str,
+ graph_config: Mapping[str, Any],
+ allow_human_input: bool = True,
+ suspended_tools: Sequence[WorkflowToolContainerPayload] = (),
+ snapshot: WorkflowToolSnapshot | None = None,
+ ) -> MaterializedWorkflowTools:
+ """Restore saved definitions, or materialize current providers for new/legacy runs."""
+ sources: dict[str, WorkflowToolSource] = {}
+ tools: dict[str, WorkflowTool] = {}
+ pending = [graph_config]
+ for tool in suspended_tools:
+ source = self._repository.get_source(
+ tenant_id=tenant_id,
+ app_id=tool.source_app_id,
+ workflow_id=tool.source_workflow_id,
+ version=tool.source_workflow_version,
+ )
+ if source is None:
+ raise ValueError("Workflow Tool source was not found")
+ sources[source.workflow_id] = source
+ pending.append(source.graph_config)
+
+ if snapshot is not None:
+ for provider_id, definition in snapshot.tools.items():
+ source = self._repository.get_source(
+ tenant_id=tenant_id,
+ app_id=definition.app_id,
+ workflow_id=definition.workflow_id,
+ version=definition.version,
+ )
+ if source is None:
+ raise ValueError("Workflow Tool source was not found")
+ sources[source.workflow_id] = source
+ pending.append(source.graph_config)
+ tools[provider_id] = WorkflowTool(
+ workflow_app_id=source.app_id,
+ workflow_id=source.workflow_id,
+ workflow_as_tool_id=provider_id,
+ version=definition.version,
+ workflow_call_depth=0,
+ entity=definition.entity.model_copy(deep=True),
+ runtime=ToolRuntime(tenant_id=tenant_id),
+ label=definition.label,
+ )
+
+ visited: set[str] = set()
+ while pending:
+ provider_ids: set[str] = set()
+ for graph in pending:
+ for node in graph.get("nodes", []):
+ data = node.get("data", {})
+ if not allow_human_input and data.get("type") == "human-input":
+ raise ValueError("Human Input nodes are not supported in this execution context.")
+ if data.get("type") == "tool" and data.get("provider_type") == "workflow":
+ provider_id = data.get("provider_id")
+ if isinstance(provider_id, str) and provider_id not in visited:
+ provider_ids.add(provider_id)
+ # A present snapshot is authoritative, including an empty registry.
+ # Never resolve mutable provider records after a durable pause.
+ if snapshot is not None or not provider_ids:
+ break
+ visited.update(provider_ids)
+ pending = []
+ for source in self._repository.get_by_provider_ids(tenant_id=tenant_id, provider_ids=tuple(provider_ids)):
+ if source.tool is not None:
+ tools[source.tool.workflow_as_tool_id] = source.tool
+ if source.workflow_id not in sources:
+ pending.append(source.graph_config)
+ sources[source.workflow_id] = source
+ return MaterializedWorkflowTools(sources=sources, tools=tools)
diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py
index 5c4aa6d6137012..0587e7eb6b21e6 100644
--- a/api/tasks/remove_app_and_related_data_task.py
+++ b/api/tasks/remove_app_and_related_data_task.py
@@ -131,8 +131,8 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
_delete_app_dataset_joins(tenant_id, app_id)
_delete_workflow_agent_node_bindings(tenant_id, app_id)
_delete_app_workflows(tenant_id, app_id)
- _delete_app_workflow_runs(tenant_id, app_id)
_delete_app_workflow_node_executions(tenant_id, app_id)
+ _delete_app_workflow_runs(tenant_id, app_id)
_delete_app_workflow_app_logs(tenant_id, app_id)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ARCHIVE_STORAGE_ENABLED:
_delete_app_workflow_archive_logs(tenant_id, app_id)
diff --git a/api/tasks/workflow_node_execution_tasks.py b/api/tasks/workflow_node_execution_tasks.py
index 0bec9aa0f79c9f..f89629bbc72461 100644
--- a/api/tasks/workflow_node_execution_tasks.py
+++ b/api/tasks/workflow_node_execution_tasks.py
@@ -10,10 +10,11 @@
from typing import Any
from celery import shared_task
-from sqlalchemy import select
+from sqlalchemy import and_, or_, select
from core.db.session_factory import session_factory
-from core.workflow.node_execution_process_data import preserve_workflow_agent_binding_id
+from core.workflow.node_execution import WorkflowNodeExecution as DifyWorkflowNodeExecution
+from core.workflow.node_execution_process_data import keep_agent_and_tool_ids
from graphon.entities.workflow_node_execution import (
WorkflowNodeExecution,
)
@@ -26,6 +27,80 @@
logger = logging.getLogger(__name__)
+@shared_task(queue="workflow_storage", bind=True, max_retries=3, default_retry_delay=60)
+def save_workflow_node_executions_task(
+ self,
+ executions_data: list[dict[str, Any]],
+ tenant_id: str,
+ app_id: str,
+ triggered_from: str,
+ creator_user_id: str,
+ creator_user_role: str,
+) -> bool:
+ """Upsert an owner-scoped cleanup batch atomically, including missing start rows."""
+ try:
+ executions = [DifyWorkflowNodeExecution.model_validate(data) for data in executions_data]
+ if not executions:
+ return True
+ with session_factory.create_session() as session, session.begin():
+ for execution in executions:
+ if execution.status in (
+ WorkflowNodeExecutionStatus.RUNNING,
+ WorkflowNodeExecutionStatus.PAUSED,
+ WorkflowNodeExecutionStatus.RETRY,
+ ):
+ apply_workflow_stop_to_node(
+ execution,
+ get_stopped_workflow_run(
+ session, tenant_id=tenant_id, workflow_run_id=execution.workflow_execution_id, lock=True
+ ),
+ )
+ query = WorkflowNodeExecutionModel.preload_offload_data(select(WorkflowNodeExecutionModel)).where(
+ WorkflowNodeExecutionModel.tenant_id == tenant_id,
+ WorkflowNodeExecutionModel.app_id == app_id,
+ WorkflowNodeExecutionModel.triggered_from == triggered_from,
+ or_(
+ *(
+ and_(
+ WorkflowNodeExecutionModel.id == execution.id,
+ WorkflowNodeExecutionModel.workflow_id == execution.workflow_id,
+ WorkflowNodeExecutionModel.workflow_run_id == execution.workflow_execution_id,
+ )
+ for execution in executions
+ )
+ ),
+ )
+ existing_models = {model.id: model for model in session.scalars(query)}
+ for execution in executions:
+ existing = existing_models.get(execution.id)
+ if existing is None:
+ session.add(
+ _create_node_execution_from_domain(
+ execution,
+ tenant_id,
+ app_id,
+ WorkflowNodeExecutionTriggeredFrom(triggered_from),
+ creator_user_id,
+ CreatorUserRole(creator_user_role),
+ )
+ )
+ continue
+ inputs, process_data, outputs = existing.inputs, existing.process_data, existing.outputs
+ _update_node_execution_from_domain(existing, execution)
+ if existing.inputs_truncated:
+ existing.inputs = inputs
+ if existing.process_data_truncated:
+ existing.process_data = process_data
+ if existing.outputs_truncated:
+ existing.outputs = outputs
+ return True
+ except Exception as error:
+ logger.exception(
+ "Failed to save workflow node execution batch: %s", [data.get("id") for data in executions_data]
+ )
+ raise self.retry(exc=error, countdown=60 * (2**self.request.retries)) from error
+
+
@shared_task(queue="workflow_storage", bind=True, max_retries=3, default_retry_delay=60)
def save_workflow_node_execution_task(
self,
@@ -53,7 +128,7 @@ def save_workflow_node_execution_task(
try:
with session_factory.create_session() as session:
# Deserialize execution data
- execution = WorkflowNodeExecution.model_validate(execution_data)
+ execution = DifyWorkflowNodeExecution.model_validate(execution_data)
if execution.status in (
WorkflowNodeExecutionStatus.RUNNING,
@@ -119,6 +194,9 @@ def _create_node_execution_from_domain(
node_execution.tenant_id = tenant_id
node_execution.app_id = app_id
node_execution.workflow_id = execution.workflow_id
+ if isinstance(execution, DifyWorkflowNodeExecution):
+ node_execution.triggered_from_workflow_id = execution.triggered_from_workflow_id
+ node_execution.triggered_from_node_execution_id = execution.triggered_from_node_execution_id
node_execution.triggered_from = triggered_from
node_execution.workflow_run_id = execution.workflow_execution_id
node_execution.index = execution.index
@@ -173,10 +251,16 @@ def _update_node_execution_from_domain(node_execution: WorkflowNodeExecutionMode
)
):
return
+ if isinstance(execution, DifyWorkflowNodeExecution):
+ if execution.triggered_from_workflow_id is not None:
+ node_execution.triggered_from_workflow_id = execution.triggered_from_workflow_id
+ if execution.triggered_from_node_execution_id is not None:
+ node_execution.triggered_from_node_execution_id = execution.triggered_from_node_execution_id
+
# Update serialized data
json_converter = WorkflowRuntimeTypeConverter()
node_execution.inputs = json.dumps(json_converter.to_json_encodable(execution.inputs)) if execution.inputs else "{}"
- process_data = preserve_workflow_agent_binding_id(node_execution.process_data_dict, execution.process_data)
+ process_data = keep_agent_and_tool_ids(node_execution.process_data_dict, execution.process_data)
node_execution.process_data = (
json.dumps(json_converter.to_json_encodable(process_data)) if process_data is not None else "{}"
)
diff --git a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py
index 27b32207b82e42..4c7c1d666e2b64 100644
--- a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py
+++ b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py
@@ -8,7 +8,6 @@
from sqlalchemy.orm import Session
from core.tools.entities.tool_entities import WorkflowToolParameterConfiguration
-from core.tools.errors import WorkflowToolHumanInputNotSupportedError
from models.tools import WorkflowToolProvider
from models.workflow import Workflow as WorkflowModel
from services.app_service import AppService, CreateAppParams
@@ -514,17 +513,10 @@ def test_create_workflow_tool_workflow_not_found_error(
assert tool_count == 0
- def test_create_workflow_tool_human_input_node_error(
+ def test_create_workflow_tool_with_human_input_node_success(
self, db_session_with_containers: Session, 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
- """
+ """A workflow containing Human Input can be published as a tool."""
fake = Faker()
# Create test data
@@ -547,29 +539,29 @@ def test_create_workflow_tool_human_input_node_error(
db_session_with_containers.commit()
tool_parameters = self._create_test_workflow_tool_parameters()
- with pytest.raises(WorkflowToolHumanInputNotSupportedError) 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"
+ tool_name = fake.word()
+ result = WorkflowToolManageService.create_workflow_tool(
+ user_id=account.id,
+ tenant_id=account.current_tenant.id,
+ workflow_app_id=app.id,
+ name=tool_name,
+ label=fake.word(),
+ icon={"type": "emoji", "emoji": "🔧"},
+ description=fake.text(max_nb_chars=200),
+ parameters=tool_parameters,
+ )
- tool_count = (
+ assert result == {"result": "success"}
+ created_tool = (
db_session_with_containers.query(WorkflowToolProvider)
.where(
WorkflowToolProvider.tenant_id == account.current_tenant.id,
+ WorkflowToolProvider.app_id == app.id,
)
- .count()
+ .first()
)
-
- assert tool_count == 0
+ assert created_tool is not None
+ assert created_tool.name == tool_name
def test_update_workflow_tool_success(
self, db_session_with_containers: Session, mock_external_service_dependencies
@@ -659,17 +651,10 @@ def test_update_workflow_tool_success(
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(
+ def test_update_workflow_tool_with_human_input_node_success(
self, db_session_with_containers: Session, 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
- """
+ """A workflow tool can be updated after adding a Human Input node."""
fake = Faker()
# Create test data
@@ -700,8 +685,6 @@ def test_update_workflow_tool_human_input_node_error(
.first()
)
- original_name = created_tool.name
-
workflow.graph = json.dumps(
{
"nodes": [
@@ -714,22 +697,21 @@ def test_update_workflow_tool_human_input_node_error(
)
db_session_with_containers.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"
+ updated_name = fake.word()
+ result = WorkflowToolManageService.update_workflow_tool(
+ user_id=account.id,
+ tenant_id=account.current_tenant.id,
+ workflow_tool_id=created_tool.id,
+ name=updated_name,
+ label=fake.word(),
+ icon={"type": "emoji", "emoji": "⚙️"},
+ description=fake.text(max_nb_chars=200),
+ parameters=initial_tool_parameters,
+ )
+ assert result == {"result": "success"}
db_session_with_containers.refresh(created_tool)
- assert created_tool.name == original_name
+ assert created_tool.name == updated_name
def test_update_workflow_tool_not_found_error(
self, db_session_with_containers: Session, mock_external_service_dependencies
diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_human_input_completion.py b/api/tests/unit_tests/core/app/apps/test_workflow_human_input_completion.py
index ea58fb2a358e27..654a8338b7acab 100644
--- a/api/tests/unit_tests/core/app/apps/test_workflow_human_input_completion.py
+++ b/api/tests/unit_tests/core/app/apps/test_workflow_human_input_completion.py
@@ -30,6 +30,7 @@
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
from core.workflow.node_runtime import DifyFileReferenceFactory
+from core.workflow.nodes.human_input.boundary import human_input_container_selector
from core.workflow.nodes.human_input.callback import DifyHITLCallback
from core.workflow.nodes.human_input.entities import (
FileInputConfig,
@@ -158,6 +159,7 @@ def _make_paused_workflow(
state.graph_execution.start()
for form in forms:
state.graph_execution.pause(HitlRequired(session_id=form.id, node_id=form.node_id, node_title="Approval"))
+ state.variable_pool.add(human_input_container_selector(form.id), "outer-tool")
state.defer_ready_task(StartTask(frame_id=ROOT_FRAME_ID, node_id=graph.root_node.id))
return WorkflowEntry(
graph=graph,
@@ -241,7 +243,7 @@ def test_resume_publishes_only_the_completed_form_among_repeated_node_invocation
assert len(completions) == 1
completion = completions[0]
assert completion.form_id == selected.id
- assert completion.node_id == "same-human-node"
+ assert completion.node_id == "outer-tool"
if status == HumanInputFormStatus.SUBMITTED:
assert isinstance(completion, QueueHumanInputFormFilledEvent)
assert (completion.action_id, completion.action_text, completion.rendered_content) == (
@@ -427,7 +429,7 @@ def test_waiting_form_expiring_during_resume_publishes_timeout_before_success(
assert isinstance(published[-1], QueueWorkflowSucceededEvent)
timeouts = [event for event in published if isinstance(event, QueueHumanInputFormTimeoutEvent)]
assert [event.form_id for event in timeouts] == [form.id]
- assert timeouts[0].node_id == "same-human-node"
+ assert timeouts[0].node_id == "outer-tool"
assert published.index(timeouts[0]) < len(published) - 1
sqlite_session.refresh(form)
assert form.status == HumanInputFormStatus.TIMEOUT
diff --git a/api/tests/unit_tests/core/app/layers/test_execution_context_layer.py b/api/tests/unit_tests/core/app/layers/test_execution_context_layer.py
index 74175c1a282ed1..3a4ab5c5290826 100644
--- a/api/tests/unit_tests/core/app/layers/test_execution_context_layer.py
+++ b/api/tests/unit_tests/core/app/layers/test_execution_context_layer.py
@@ -1,5 +1,6 @@
from concurrent.futures import ThreadPoolExecutor
from contextvars import ContextVar
+from functools import partial
from typing import override
import pytest
@@ -9,17 +10,19 @@
from core.app.layers import execution_context_layer as module
from core.app.layers.execution_context_layer import ExecutionContextLayer
from core.app.workflow.file_runtime import create_dify_workflow_file_runtime, init_app
-from core.workflow.node_factory import DifyNodeFactory
+from core.workflow.workflow_tool_container_handler import WorkflowToolContainerHandler
from dify_app import DifyApp
from graphon.engine import Engine
from graphon.engine.layer import Layer
from graphon.engine_events import GraphRunSucceededEvent
from graphon.entities.base_node_data import BaseNodeData
from graphon.file.runtime import peek_workflow_file_runtime, use_workflow_file_runtime
-from graphon.graph import Graph
from graphon.nodes.base.node import Node
-from graphon.runtime import RuntimeState, VariablePool
-from tests.workflow_test_utils import build_test_graph_init_params
+from tests.unit_tests.core.workflow.test_workflow_tool_container import (
+ _outer_graph,
+ _workflow_tool_node,
+ _workflow_tool_source,
+)
def test_host_context_preserves_each_engine_file_runtime(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -28,6 +31,7 @@ def test_host_context_preserves_each_engine_file_runtime(monkeypatch: pytest.Mon
monkeypatch.setattr(module, "capture_current_context", capture_flask_context)
request_id: ContextVar[str] = ContextVar("request_id")
caller_runtime = create_dify_workflow_file_runtime()
+ source = _workflow_tool_source()
def build_engine(request: str) -> tuple[Engine, list[str]]:
runtime = create_dify_workflow_file_runtime()
@@ -49,26 +53,20 @@ def on_node_run_start(self, node: Node[BaseNodeData]) -> None:
check_context()
checked_nodes.append(node.id)
- graph_config: dict[str, object] = {
- "nodes": [
- {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}},
- {"id": "end", "data": {"type": "end", "title": "End", "outputs": []}},
- ],
- "edges": [{"source": "start", "target": "end", "sourceHandle": "source"}],
- }
- state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0)
+ sources = {source.workflow_id: source}
+ tool, _, _ = _workflow_tool_node()
engine = Engine(
- graph=Graph.init(
- graph_config=graph_config,
- root_node_id="start",
- node_factory=DifyNodeFactory(
- init_params=build_test_graph_init_params(workflow_id="workflow", graph_config=graph_config),
- runtime_state=state,
- ),
- ),
- runtime_state=state,
+ graph=_outer_graph(tool),
+ runtime_state=tool.runtime_state,
workers=2,
file_runtime=runtime,
+ container_handler_factories=(
+ partial(
+ WorkflowToolContainerHandler,
+ sources=sources,
+ execution_context_factory=host.enter_context,
+ ),
+ ),
)
engine.add_layer(host)
engine.add_layer(CheckNodeContext())
@@ -82,7 +80,7 @@ def run_engine(item: tuple[Engine, list[str]]) -> None:
for _event in engine.run():
assert peek_workflow_file_runtime() is caller_runtime
assert isinstance(_event, GraphRunSucceededEvent)
- assert set(checked_nodes) == {"start", "end"}
+ assert set(checked_nodes) == {"outer-start", "tool", "source-start", "source-end"}
assert peek_workflow_file_runtime() is caller_runtime
with ThreadPoolExecutor(max_workers=2) as pool:
diff --git a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py
index 36cc0d37bae391..42e865618d42b2 100644
--- a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py
+++ b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py
@@ -1,19 +1,41 @@
from __future__ import annotations
+from dataclasses import replace
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
+from sqlalchemy import event as sqlalchemy_event
+from sqlalchemy import select
+from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity
+from core.app.workflow.retry_history import RETRY_HISTORY_PROCESS_DATA_KEY
from core.ops.ops_trace_manager import TraceTask, TraceTaskName
+from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
+from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
+from core.workflow.node_execution_process_data import (
+ WORKFLOW_TOOL_INVOCATION_ID_KEY,
+ WORKFLOW_TOOL_ROOT_APP_ID_KEY,
+)
from core.workflow.system_variables import SystemVariableKey, build_system_variables
+from enums import WorkflowKind
+from graphon.engine.event.node_failure import NodeFailureHandler
+from graphon.engine.event.processor import NodeEventProcessor
+from graphon.engine.event.stream import EventStream
+from graphon.engine.frame import ExecutionFrame, FrameRegistry
+from graphon.engine.scheduler import Scheduler
+from graphon.engine.worker import NodeEventTask
from graphon.engine_events import (
+ EngineEvent,
GraphRunAbortedEvent,
GraphRunFailedEvent,
GraphRunPartialSucceededEvent,
+ GraphRunPausedEvent,
GraphRunStartedEvent,
GraphRunSucceededEvent,
+ NodeEvent,
NodeRunExceptionEvent,
NodeRunFailedEvent,
NodeRunPauseRequestedEvent,
@@ -30,10 +52,18 @@
WorkflowNodeExecutionStatus,
WorkflowType,
)
+from graphon.graph import Graph
from graphon.model_runtime.entities.llm_entities import LLMUsage
from graphon.node_events import NodeRunResult
+from graphon.nodes.start.entities import StartNodeData
+from graphon.nodes.start.start_node import StartNode
from graphon.runtime import ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool
+from graphon.runtime.execution import ROOT_FRAME_ID
+from models import Account, WorkflowRun
+from models.enums import WorkflowRunTriggeredFrom
+from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
from services.workflow_persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
+from tests.workflow_test_utils import build_test_graph_init_params
class _RepoRecorder:
@@ -45,6 +75,9 @@ def __init__(self) -> None:
def save(self, entity):
self.saved.append(entity)
+ def save_many(self, entities):
+ self.saved.extend(entities)
+
def save_synchronously(self, entity):
self.synchronously_saved.append(entity)
@@ -116,6 +149,495 @@ def _make_layer(
return layer, workflow_execution_repo, workflow_node_execution_repo, runtime_state
+def _make_sql_layer(session_factory, *, node_run_indices=None):
+ user = Account(name="Test", email="test@example.com")
+ user.id = "user"
+ layer, _, _, _ = _make_layer(
+ node_run_indices=node_run_indices,
+ workflow_execution_repo=SQLAlchemyWorkflowExecutionRepository(
+ session_factory=session_factory,
+ tenant_id="tenant",
+ user=user,
+ app_id="caller-app",
+ triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
+ ),
+ workflow_node_execution_repo=SQLAlchemyWorkflowNodeExecutionRepository(
+ session_factory=session_factory,
+ tenant_id="tenant",
+ user=user,
+ app_id="caller-app",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ ),
+ )
+ layer._application_generate_entity.app_config.app_id = "caller-app"
+ return layer
+
+
+def test_tool_trace_identity_survives_start_pause_and_resume():
+ layer, _, node_repo, _ = _make_layer(node_run_indices={"tool-exec": 1})
+ layer.on_event(GraphRunStartedEvent())
+ started_at = _naive_utc_now()
+ layer.on_event(
+ NodeRunStartedEvent(
+ id="tool-exec",
+ node_id="tool",
+ node_type=BuiltinNodeTypes.TOOL,
+ node_title="Nested tool",
+ provider_type="workflow",
+ provider_id="provider",
+ start_at=started_at,
+ node_run_result=NodeRunResult(process_data={WORKFLOW_TOOL_INVOCATION_ID_KEY: "invocation"}),
+ )
+ )
+ for event in (
+ NodeRunPauseRequestedEvent(
+ id="tool-exec", node_id="tool", node_type=BuiltinNodeTypes.TOOL, reason=SchedulingPause(message="pause")
+ ),
+ NodeRunSucceededEvent(id="tool-exec", node_id="tool", node_type=BuiltinNodeTypes.TOOL, start_at=started_at),
+ ):
+ layer.on_event(event)
+ execution = node_repo.saved_exec_data[-1]
+ assert execution.process_data[WORKFLOW_TOOL_INVOCATION_ID_KEY] == "invocation"
+ assert execution.metadata[WorkflowNodeExecutionMetadataKey.TOOL_INFO] == {
+ "provider_type": "workflow",
+ "provider_id": "provider",
+ }
+
+
+def _tool_source():
+ return WorkflowToolSource(
+ app_id="source-app",
+ workflow_id="source-workflow",
+ graph_config={"nodes": [{"id": "source-loop", "data": {"type": "loop"}}], "edges": []},
+ features_dict={},
+ environment_variables=[],
+ workflow_kind=WorkflowKind.STANDARD,
+ )
+
+
+def test_workflow_tool_agent_nodes_persist_in_source_app_under_only_root_run(sqlite_session_factory):
+ layer = _make_sql_layer(sqlite_session_factory, node_run_indices={"caller-exec": 1, "source-agent-exec": 1})
+ listen = layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "caller-exec")
+ layer.on_graph_start()
+ layer.on_event(GraphRunStartedEvent())
+ started_at = _naive_utc_now()
+ layer.on_event(
+ NodeRunStartedEvent(
+ id="caller-exec",
+ node_id="caller",
+ node_type=BuiltinNodeTypes.TOOL,
+ node_title="Workflow Tool",
+ start_at=started_at,
+ )
+ )
+ listen(
+ NodeRunStartedEvent(
+ id="source-agent-exec",
+ node_id="agent",
+ node_type=BuiltinNodeTypes.AGENT,
+ node_version="2",
+ node_title="Agent",
+ start_at=started_at,
+ container_id="source-loop",
+ node_run_result=NodeRunResult(
+ process_data={WORKFLOW_TOOL_ROOT_APP_ID_KEY: "untrusted-app"}, metadata={"loop_id": "source-loop"}
+ ),
+ )
+ )
+
+ with sqlite_session_factory() as session:
+ rows = {row.id: row for row in session.scalars(select(WorkflowNodeExecutionModel))}
+ assert rows["caller-exec"].app_id == "caller-app"
+ source_row = rows["source-agent-exec"]
+ assert (source_row.tenant_id, source_row.app_id, source_row.workflow_id, source_row.workflow_run_id) == (
+ "tenant",
+ "source-app",
+ "source-workflow",
+ "run-id",
+ )
+ assert source_row.status == "running"
+ assert source_row.process_data_dict[WORKFLOW_TOOL_ROOT_APP_ID_KEY] == "caller-app"
+ assert source_row.execution_metadata_dict["loop_id"] == "source-loop"
+ assert session.scalars(select(WorkflowRun)).one().workflow_id == "workflow-id"
+
+ listen(
+ NodeRunSucceededEvent(
+ id="source-agent-exec",
+ node_id="agent",
+ node_type=BuiltinNodeTypes.AGENT,
+ node_title="Agent",
+ start_at=started_at,
+ node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, outputs={"answer": "ok"}),
+ )
+ )
+ layer.on_event(GraphRunSucceededEvent(outputs={"result": "caller-result"}))
+
+ with sqlite_session_factory() as session:
+ run = session.scalars(select(WorkflowRun)).one()
+ assert (run.app_id, run.workflow_id, run.status, run.outputs_dict) == (
+ "caller-app",
+ "workflow-id",
+ "succeeded",
+ {"result": "caller-result"},
+ )
+ row = session.get(WorkflowNodeExecutionModel, "source-agent-exec")
+ assert row.status == "succeeded"
+ assert row.outputs_dict == {"answer": "ok"}
+ assert row.process_data_dict[WORKFLOW_TOOL_ROOT_APP_ID_KEY] == "caller-app"
+
+
+def test_workflow_tool_resume_updates_existing_running_container(sqlite_session_factory):
+ layer = _make_sql_layer(sqlite_session_factory, node_run_indices={"loop-exec": 1})
+ listen = layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "caller-exec")
+ layer.on_event(GraphRunStartedEvent())
+ started_at = _naive_utc_now()
+ listen(
+ NodeRunStartedEvent(
+ id="loop-exec",
+ node_id="source-loop",
+ node_type=BuiltinNodeTypes.LOOP,
+ node_title="Approval Loop",
+ start_at=started_at,
+ )
+ )
+ layer.on_event(GraphRunPausedEvent(outputs={}, reasons=[]))
+
+ resumed = _make_sql_layer(sqlite_session_factory, node_run_indices={"loop-exec": 1})
+ resume_listener = resumed.create_workflow_tool_event_listener(
+ _tool_source(),
+ "workflow-id",
+ "caller-exec",
+ node_executions=resumed._workflow_node_execution_repository.for_workflow_tool(
+ "source-app"
+ ).get_by_workflow_execution("run-id"),
+ )
+ resumed.on_graph_start()
+ resumed.on_event(GraphRunStartedEvent(reason=WorkflowStartReason.RESUMPTION))
+ resume_listener(
+ NodeRunSucceededEvent(
+ id="loop-exec",
+ node_id="source-loop",
+ node_type=BuiltinNodeTypes.LOOP,
+ node_title="Approval Loop",
+ start_at=started_at,
+ node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, outputs={"approved": True}),
+ )
+ )
+ resumed.on_event(GraphRunSucceededEvent(outputs={"answer": "approved"}))
+
+ with sqlite_session_factory() as session:
+ node = session.scalars(select(WorkflowNodeExecutionModel)).one()
+ assert (node.app_id, node.workflow_id, node.workflow_run_id, node.status) == (
+ "source-app",
+ "source-workflow",
+ "run-id",
+ "succeeded",
+ )
+ assert node.outputs_dict == {"approved": True}
+ run = session.scalars(select(WorkflowRun)).one()
+ assert (run.app_id, run.workflow_id, run.status) == ("caller-app", "workflow-id", "succeeded")
+
+
+@pytest.mark.parametrize("source_workflow_id", ["older-workflow", "workflow-id"])
+def test_root_resume_isolates_same_app_workflow_tool_origin(sqlite_session_factory, source_workflow_id):
+ source = replace(_tool_source(), app_id="caller-app", workflow_id=source_workflow_id)
+ layer = _make_sql_layer(sqlite_session_factory, node_run_indices={"caller-exec": 1, "older-exec": 1})
+ listen = layer.create_workflow_tool_event_listener(source, "workflow-id", "caller-exec")
+ layer.on_event(GraphRunStartedEvent())
+ started_at = _naive_utc_now()
+ layer.on_event(
+ NodeRunStartedEvent(
+ id="caller-exec",
+ node_id="caller",
+ node_type=BuiltinNodeTypes.TOOL,
+ node_title="Caller",
+ start_at=started_at,
+ )
+ )
+ listen(
+ NodeRunStartedEvent(
+ id="older-exec",
+ node_id="older-node",
+ node_type=BuiltinNodeTypes.LLM,
+ node_title="Older version",
+ start_at=started_at,
+ )
+ )
+ layer.on_event(GraphRunPausedEvent())
+
+ resumed = _make_sql_layer(sqlite_session_factory, node_run_indices={"caller-exec": 1, "older-exec": 1})
+ resumed.set_node_execution_history(resumed._workflow_node_execution_repository.get_by_workflow_execution("run-id"))
+ resume_listener = resumed.create_workflow_tool_event_listener(
+ source,
+ "workflow-id",
+ "caller-exec",
+ node_executions=resumed._workflow_node_execution_repository.for_workflow_tool(
+ source.app_id
+ ).get_by_workflow_execution("run-id"),
+ )
+ resumed.on_event(GraphRunStartedEvent(reason=WorkflowStartReason.RESUMPTION))
+ resume_listener(
+ NodeRunSucceededEvent(
+ id="older-exec",
+ node_id="older-node",
+ node_type=BuiltinNodeTypes.LLM,
+ start_at=started_at,
+ node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED),
+ )
+ )
+ resumed.on_event(GraphRunFailedEvent(error="caller failed later"))
+
+ with sqlite_session_factory() as session:
+ rows = {row.id: row for row in session.scalars(select(WorkflowNodeExecutionModel))}
+ assert rows["caller-exec"].status == "failed"
+ assert rows["older-exec"].status == "succeeded"
+ assert rows["older-exec"].workflow_id == source_workflow_id
+
+
+@pytest.mark.parametrize("source", [False, True])
+@pytest.mark.parametrize("outcome", ["succeeded", "failed", "aborted", "paused"])
+def test_retry_starts_preserve_original_execution_and_attempt_history(
+ sqlite_session_factory: sessionmaker[Session],
+ monkeypatch: pytest.MonkeyPatch,
+ source: bool,
+ outcome: str,
+) -> None:
+ layer = _make_sql_layer(sqlite_session_factory, node_run_indices={"retry-exec": 1})
+ listen = (
+ layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "caller-exec")
+ if source
+ else layer.on_event
+ )
+ layer.on_event(GraphRunStartedEvent())
+ workflow_id = "source-workflow" if source else "workflow-id"
+ state = RuntimeState(workflow_id=workflow_id, variable_pool=VariablePool(), start_at=0)
+ frames = FrameRegistry()
+ node = StartNode(
+ node_id="child",
+ data=StartNodeData(title="Child", variables=[]),
+ init_params=build_test_graph_init_params(workflow_id=workflow_id),
+ runtime_state=state,
+ )
+ graph = Graph.new().add_root(node).build()
+ frames.register(
+ ExecutionFrame(
+ frame_id=ROOT_FRAME_ID,
+ graph=graph,
+ state=state,
+ scheduler=Scheduler(graph, state, ROOT_FRAME_ID),
+ failure_handler=NodeFailureHandler(graph, state.graph_execution),
+ )
+ )
+
+ event_stream = EventStream(layers=[])
+ collect = event_stream.collect
+
+ def collect_source_event(event: EngineEvent) -> None:
+ assert isinstance(event, NodeEvent)
+ listen(event)
+ collect(event)
+
+ monkeypatch.setattr(event_stream, "collect", collect_source_event)
+ processor = NodeEventProcessor(state.graph_execution, event_stream, frames, {})
+
+ def dispatch(event: NodeEvent) -> None:
+ processor.dispatch(NodeEventTask(frame_id=ROOT_FRAME_ID, event=event))
+
+ started_at = _naive_utc_now()
+ started = NodeRunStartedEvent(
+ id="retry-exec",
+ node_id="child",
+ node_type=BuiltinNodeTypes.LLM,
+ node_title="Child",
+ start_at=started_at,
+ )
+ dispatch(started)
+ for attempt in (1, 2):
+ dispatch(
+ NodeRunRetryEvent(
+ id="retry-exec",
+ node_id="child",
+ node_type=BuiltinNodeTypes.LLM,
+ node_title="Child",
+ start_at=started_at,
+ retry_index=attempt,
+ error=f"attempt-{attempt}",
+ node_run_result=NodeRunResult(outputs={"attempt": attempt}),
+ )
+ )
+ dispatch(started)
+ if outcome == "succeeded":
+ dispatch(
+ NodeRunSucceededEvent(
+ id="retry-exec",
+ node_id="child",
+ node_type=BuiltinNodeTypes.LLM,
+ start_at=started_at,
+ node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, outputs={"ok": True}),
+ )
+ )
+ elif outcome == "failed":
+ layer.on_event(GraphRunFailedEvent(error="terminal failure"))
+ elif outcome == "aborted":
+ layer.on_event(GraphRunAbortedEvent(reason="terminal failure"))
+ else:
+ commits = []
+
+ def record_commit(_session: Session) -> None:
+ commits.append(True)
+
+ sqlalchemy_event.listen(Session, "after_commit", record_commit)
+ try:
+ layer.persist_unfinished_nodes_synchronously()
+ finally:
+ sqlalchemy_event.remove(Session, "after_commit", record_commit)
+ assert len(commits) == 1
+
+ with sqlite_session_factory() as session:
+ row = session.scalars(select(WorkflowNodeExecutionModel)).one()
+ assert row.index == 1
+ assert row.created_at == started_at
+ assert row.workflow_id == workflow_id
+ assert row.app_id == ("source-app" if source else "caller-app")
+ if outcome == "succeeded":
+ assert row.status == "succeeded"
+ assert row.outputs_dict == {"ok": True}
+ elif outcome == "paused":
+ assert row.status == "retry"
+ assert row.finished_at is None
+ else:
+ assert row.status == "failed"
+ assert row.error == "terminal failure"
+ assert row.finished_at is not None
+ history = row.process_data_dict[RETRY_HISTORY_PROCESS_DATA_KEY]
+ assert [(attempt["retry_index"], attempt["error"]) for attempt in history] == [
+ (1, "attempt-1"),
+ (2, "attempt-2"),
+ ]
+
+
+@pytest.mark.parametrize(
+ "event", [GraphRunFailedEvent(error="failed", exceptions_count=1), GraphRunAbortedEvent(reason="aborted")]
+)
+def test_root_terminal_failure_finishes_running_workflow_tool_nodes(sqlite_session_factory, event):
+ layer = _make_sql_layer(sqlite_session_factory, node_run_indices={"source-exec": 1})
+ listen = layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "caller-exec")
+ layer.on_event(GraphRunStartedEvent())
+ listen(
+ NodeRunStartedEvent(
+ id="source-exec",
+ node_id="child",
+ node_type=BuiltinNodeTypes.LLM,
+ node_title="Child",
+ start_at=_naive_utc_now(),
+ )
+ )
+
+ layer.on_event(event)
+
+ with sqlite_session_factory() as session:
+ node = session.scalars(select(WorkflowNodeExecutionModel)).one()
+ assert node.status == "failed"
+ assert node.error == (event.error if isinstance(event, GraphRunFailedEvent) else event.reason)
+ assert node.finished_at is not None
+ assert session.scalars(select(WorkflowRun)).one().workflow_id == "workflow-id"
+
+
+def test_terminal_failure_batches_only_unfinished_nodes_per_source(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ ids = (
+ "root-a",
+ "root-b",
+ "root-retry",
+ "root-paused",
+ "source-a",
+ "source-b",
+ "source-retry",
+ "source-paused",
+ "source-done",
+ )
+ layer = _make_sql_layer(sqlite_session_factory, node_run_indices=dict.fromkeys(ids, 1))
+ listen = layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "root-a")
+ layer.on_event(GraphRunStartedEvent())
+ started_at = _naive_utc_now()
+ for execution_id in ids:
+ listener = listen if execution_id.startswith("source") else layer.on_event
+ listener(
+ NodeRunStartedEvent(
+ id=execution_id,
+ node_id=execution_id,
+ node_type=BuiltinNodeTypes.CODE,
+ node_title="Code",
+ start_at=started_at,
+ )
+ )
+ if execution_id.endswith("paused"):
+ listener(
+ NodeRunPauseRequestedEvent(
+ id=execution_id,
+ node_id=execution_id,
+ node_type=BuiltinNodeTypes.CODE,
+ reason=SchedulingPause(message="pause"),
+ )
+ )
+ elif execution_id.endswith("retry"):
+ listener(
+ NodeRunRetryEvent(
+ id=execution_id,
+ node_id=execution_id,
+ node_type=BuiltinNodeTypes.CODE,
+ node_title="Code",
+ start_at=started_at,
+ retry_index=1,
+ error="retry failed",
+ )
+ )
+ listen(
+ NodeRunSucceededEvent(
+ id="source-done",
+ node_id="source-done",
+ node_type=BuiltinNodeTypes.CODE,
+ start_at=started_at,
+ node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, outputs={"answer": "kept"}),
+ )
+ )
+ commits = []
+
+ def record_commit(_session: Session) -> None:
+ commits.append(True)
+
+ sqlalchemy_event.listen(Session, "after_commit", record_commit)
+ try:
+ layer.on_event(GraphRunFailedEvent(error="terminal failure", exceptions_count=0))
+ finally:
+ sqlalchemy_event.remove(Session, "after_commit", record_commit)
+
+ with sqlite_session_factory() as session:
+ rows = {row.id: row for row in session.scalars(select(WorkflowNodeExecutionModel))}
+ assert {key: row.status for key, row in rows.items()} == {
+ "root-a": "failed",
+ "root-b": "failed",
+ "root-retry": "failed",
+ "root-paused": "paused",
+ "source-a": "failed",
+ "source-b": "failed",
+ "source-retry": "failed",
+ "source-paused": "paused",
+ "source-done": "succeeded",
+ }
+ assert rows["source-done"].outputs_dict == {"answer": "kept"}
+ for execution_id in ("root-a", "root-b", "root-retry", "source-a", "source-b", "source-retry"):
+ assert rows[execution_id].error == "terminal failure"
+ assert rows[execution_id].finished_at is not None
+ for execution_id in ("root-retry", "source-retry"):
+ assert rows[execution_id].process_data_dict[RETRY_HISTORY_PROCESS_DATA_KEY][0]["error"] == "retry failed"
+ assert rows["source-a"].triggered_from_node_execution_id == "root-a"
+ assert rows["source-a"].app_id == "source-app"
+ # One commit per source owner, plus the workflow summary.
+ assert len(commits) == 3
+
+
class TestWorkflowPersistenceLayer:
def test_get_execution_id_requires_system_variable(self):
layer, _, _, _ = _make_layer(build_system_variables())
@@ -546,6 +1068,7 @@ def test_handle_node_pause_requested_skips_outputs(self):
process_data={
"p": 1,
"workflow_agent_binding_id": "workflow-binding-1",
+ "workflow_tool_invocation_id": "tool-call-1",
},
metadata={},
)
@@ -562,6 +1085,7 @@ def test_handle_node_pause_requested_skips_outputs(self):
assert domain_execution.inputs == {"old": True}
assert domain_execution.process_data == {
"workflow_agent_binding_id": "workflow-binding-1",
+ "workflow_tool_invocation_id": "tool-call-1",
}
def test_handle_node_retry_preserves_workflow_agent_binding_identity(self):
@@ -590,12 +1114,14 @@ def test_handle_node_retry_preserves_workflow_agent_binding_identity(self):
node_run_result=NodeRunResult(
process_data={
"workflow_agent_binding_id": "workflow-binding-1",
+ "workflow_tool_invocation_id": "tool-call-1",
},
),
)
)
assert layer._node_execution_cache["exec"].process_data["workflow_agent_binding_id"] == "workflow-binding-1"
+ assert layer._node_execution_cache["exec"].process_data["workflow_tool_invocation_id"] == "tool-call-1"
def test_get_node_execution_raises_for_missing(self):
layer, _, _, _ = _make_layer()
diff --git a/api/tests/unit_tests/core/repositories/test_celery_workflow_node_execution_repository.py b/api/tests/unit_tests/core/repositories/test_celery_workflow_node_execution_repository.py
index e064879b946682..0551ad5a22dd43 100644
--- a/api/tests/unit_tests/core/repositories/test_celery_workflow_node_execution_repository.py
+++ b/api/tests/unit_tests/core/repositories/test_celery_workflow_node_execution_repository.py
@@ -12,14 +12,20 @@
from core.repositories.celery_workflow_node_execution_repository import CeleryWorkflowNodeExecutionRepository
from core.repositories.factory import OrderConfig
+from core.workflow.node_execution_process_data import WORKFLOW_TOOL_ROOT_APP_ID_KEY
from graphon.entities.workflow_node_execution import (
WorkflowNodeExecution,
WorkflowNodeExecutionStatus,
)
from graphon.enums import BuiltinNodeTypes
+from graphon.variables.segments import ArrayFileSegment, ArrayObjectSegment, StringSegment
from libs.datetime_utils import naive_utc_now
-from models import Account, EndUser, Tenant
+from models import Account, EndUser, Tenant, WorkflowNodeExecutionModel
from models.workflow import WorkflowNodeExecutionTriggeredFrom
+from tasks.workflow_node_execution_tasks import (
+ save_workflow_node_execution_task,
+ save_workflow_node_executions_task,
+)
RESOURCE_TENANT_ID = "resource-tenant-id"
@@ -65,6 +71,136 @@ def sample_workflow_node_execution():
class TestCeleryWorkflowNodeExecutionRepository:
"""Test cases for CeleryWorkflowNodeExecutionRepository."""
+ @pytest.mark.parametrize("writer", ["save", "save_many"])
+ def test_async_save_preserves_typed_cache_and_plain_history(
+ self, writer, sqlite_session_factory, account, sample_workflow_node_execution
+ ):
+ repo = CeleryWorkflowNodeExecutionRepository(
+ session_factory=sqlite_session_factory,
+ tenant_id=RESOURCE_TENANT_ID,
+ user=account,
+ app_id="test-app",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+ literal_object = {"value_type": "string", "value": "user data"}
+ execution = sample_workflow_node_execution
+ execution.node_type = BuiltinNodeTypes.TOOL
+ execution.status = WorkflowNodeExecutionStatus.SUCCEEDED
+ execution.inputs = {"prompt": StringSegment(value="input")}
+ execution.process_data = {"nested": {"messages": [StringSegment(value="processing")]}}
+ execution.outputs = {
+ "text": StringSegment(value="done"),
+ "files": ArrayFileSegment(value=[]),
+ "json": ArrayObjectSegment(value=[literal_object]),
+ "literal_object": literal_object,
+ }
+ task = save_workflow_node_execution_task if writer == "save" else save_workflow_node_executions_task
+ with patch.object(task, "delay") as delay:
+ if writer == "save":
+ repo.save(execution)
+ else:
+ repo.save_many([execution])
+
+ delay.assert_called_once()
+ assert task.run(**delay.call_args.kwargs) is True
+
+ expected_outputs = {
+ "text": "done",
+ "files": [],
+ "json": [literal_object],
+ "literal_object": literal_object,
+ }
+ with sqlite_session_factory() as observer:
+ stored = observer.get(WorkflowNodeExecutionModel, execution.id)
+ assert stored is not None
+ assert stored.inputs_dict == {"prompt": "input"}
+ assert stored.process_data_dict == {"nested": {"messages": ["processing"]}}
+ assert stored.outputs_dict == expected_outputs
+
+ (cached,) = repo.get_by_workflow_execution(execution.workflow_execution_id)
+ assert cached is execution
+ assert isinstance(cached.inputs["prompt"], StringSegment)
+ assert isinstance(cached.process_data["nested"]["messages"][0], StringSegment)
+ assert isinstance(cached.outputs["text"], StringSegment)
+ assert isinstance(cached.outputs["files"], ArrayFileSegment)
+ assert isinstance(cached.outputs["json"], ArrayObjectSegment)
+ assert cached.outputs["literal_object"] == literal_object
+
+ history_repo = CeleryWorkflowNodeExecutionRepository(
+ session_factory=sqlite_session_factory,
+ tenant_id=RESOURCE_TENANT_ID,
+ user=account,
+ app_id="test-app",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+ (restored,) = history_repo.get_by_workflow_execution(execution.workflow_execution_id)
+ assert restored.inputs == {"prompt": "input"}
+ assert restored.process_data == {"nested": {"messages": ["processing"]}}
+ assert restored.outputs == expected_outputs
+
+ def test_trace_read_keeps_source_nodes_out_of_runtime_cache(
+ self, sqlite_session_factory, end_user, sample_workflow_node_execution
+ ):
+ caller = CeleryWorkflowNodeExecutionRepository(
+ session_factory=sqlite_session_factory,
+ tenant_id=RESOURCE_TENANT_ID,
+ user=end_user,
+ app_id="caller-app",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+ root = sample_workflow_node_execution
+ caller.save_synchronously(root)
+ source = caller.for_workflow_tool("source-app")
+ child = root.model_copy(
+ update={
+ "id": "child",
+ "node_execution_id": "child",
+ "process_data": {WORKFLOW_TOOL_ROOT_APP_ID_KEY: "caller-app"},
+ }
+ )
+ source.save_synchronously(child)
+ assert [node.id for node in caller.get_by_workflow_execution(root.workflow_execution_id)] == [root.id]
+ assert [node.id for node in source.get_by_workflow_execution(root.workflow_execution_id)] == [child.id]
+ assert [node.id for node in caller.get_by_workflow_execution(root.workflow_execution_id)] == [root.id]
+
+ @patch("core.repositories.celery_workflow_node_execution_repository.save_workflow_node_execution_task")
+ def test_workflow_tool_scope_keeps_async_backend_and_isolates_same_app_history(
+ self, mock_task, sqlite_session_factory, end_user, sample_workflow_node_execution
+ ):
+ caller = CeleryWorkflowNodeExecutionRepository(
+ session_factory=sqlite_session_factory,
+ tenant_id=RESOURCE_TENANT_ID,
+ user=end_user,
+ app_id="caller-app",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+ source = caller.for_workflow_tool("caller-app")
+ root_node = sample_workflow_node_execution
+ source_node = root_node.model_copy(update={"id": "source-exec", "node_execution_id": "source-exec"})
+ caller.save_synchronously(root_node)
+ source.save_synchronously(source_node)
+ source_node.status = WorkflowNodeExecutionStatus.SUCCEEDED
+ source_node.outputs = {"answer": "ok"}
+ source.save(source_node)
+ source.save_execution_data(source_node)
+
+ assert [node.id for node in caller.get_by_workflow_execution(root_node.workflow_execution_id)] == [root_node.id]
+ assert [node.id for node in source.get_by_workflow_execution(root_node.workflow_execution_id)] == [
+ source_node.id
+ ]
+ restored_source = caller.for_workflow_tool("caller-app")
+ assert [node.id for node in restored_source.get_by_workflow_execution(root_node.workflow_execution_id)] == [
+ source_node.id
+ ]
+ mock_task.delay.assert_called_once_with(
+ execution_data=source_node.model_dump(),
+ tenant_id=RESOURCE_TENANT_ID,
+ app_id="caller-app",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL.value,
+ creator_user_id=end_user.id,
+ creator_user_role="end_user",
+ )
+
def test_init_with_sessionmaker(self, sqlite_session_factory, account):
"""Test repository initialization with sessionmaker."""
app_id = "test-app-id"
diff --git a/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py b/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py
index 378e4cc3fbf396..3b67771dcdf294 100644
--- a/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py
+++ b/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py
@@ -27,6 +27,7 @@
_find_first,
_replace_or_append_offload,
)
+from core.workflow.node_execution_process_data import WORKFLOW_TOOL_ROOT_APP_ID_KEY
from graphon.entities import WorkflowNodeExecution
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from models import Account, EndUser
@@ -155,6 +156,29 @@ def test_init_accepts_real_engine_and_sessionmaker_and_sets_role(
assert end_user_repo._creator_user_role.value == "end_user"
+@pytest.mark.parametrize("source_app", ["app-1", "source-app"])
+def test_workflow_tool_scope_isolates_execution_history_with_same_tenant_and_creator(
+ monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session], source_app: str
+) -> None:
+ caller = _repository(monkeypatch, sqlite_session_factory, user=_end_user())
+ source = caller.for_workflow_tool(source_app)
+ caller.save_synchronously(_execution(execution_id="caller", node_execution_id="caller"))
+ source.save_synchronously(_execution(execution_id="source", node_execution_id="source"))
+
+ assert [node.id for node in caller.get_by_workflow_execution("run-1")] == ["caller"]
+ assert [node.id for node in source.get_by_workflow_execution("run-1")] == ["source"]
+ with sqlite_session_factory() as session:
+ row = session.get(WorkflowNodeExecutionModel, "source")
+ assert row is not None
+ assert (row.tenant_id, row.app_id, row.created_by, row.created_by_role, row.triggered_from) == (
+ "tenant-1",
+ source_app,
+ "end-user-1",
+ CreatorUserRole.END_USER,
+ WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ )
+
+
def test_init_rejects_invalid_factory_and_missing_tenant(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.repositories.sqlalchemy_workflow_node_execution_repository.FileService",
@@ -318,6 +342,8 @@ def test_save_execution_data_updates_existing_and_creates_missing(
inputs={"initial": True},
process_data={
"workflow_agent_binding_id": "binding-1",
+ "workflow_tool_invocation_id": "tool-call-1",
+ "workflow_tool_root_app_id": "root-app",
},
)
repo.save(existing)
@@ -334,6 +360,8 @@ def test_save_execution_data_updates_existing_and_creates_missing(
assert persisted.process_data_dict == {
"step": 3,
"workflow_agent_binding_id": "binding-1",
+ "workflow_tool_invocation_id": "tool-call-1",
+ "workflow_tool_root_app_id": "root-app",
}
missing = _execution(execution_id="missing", node_execution_id="missing-node", inputs={"new": True})
@@ -456,14 +484,9 @@ def test_get_by_workflow_execution_maps_real_rows_to_domain(
@pytest.mark.parametrize(
"repository_type", [SQLAlchemyWorkflowNodeExecutionRepository, CeleryWorkflowNodeExecutionRepository]
)
-@pytest.mark.parametrize(
- "triggered_from",
- [WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, WorkflowNodeExecutionTriggeredFrom.RAG_PIPELINE_RUN],
-)
def test_resume_history_includes_paused_nodes_without_changing_default_reads(
sqlite_session_factory: sessionmaker[Session],
repository_type: type[SQLAlchemyWorkflowNodeExecutionRepository | CeleryWorkflowNodeExecutionRepository],
- triggered_from: WorkflowNodeExecutionTriggeredFrom,
) -> None:
user = _account()
writer = SQLAlchemyWorkflowNodeExecutionRepository(
@@ -471,24 +494,68 @@ def test_resume_history_includes_paused_nodes_without_changing_default_reads(
tenant_id="tenant-1",
user=user,
app_id="app-1",
- triggered_from=triggered_from,
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
)
writer.save_synchronously(_execution(execution_id="finished", node_execution_id="finished"))
paused = _execution(execution_id="paused", node_execution_id="paused", status=WorkflowNodeExecutionStatus.PAUSED)
writer.save_synchronously(paused)
+ writer.for_workflow_tool("app-1").save_synchronously(
+ paused.model_copy(update={"id": "source-paused", "node_execution_id": "source-paused"})
+ )
reader = repository_type(
sqlite_session_factory,
tenant_id="tenant-1",
user=user,
app_id="app-1",
- triggered_from=triggered_from,
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
)
+ source_reader = reader.for_workflow_tool("app-1")
assert {node.id for node in reader.get_by_workflow_execution("run-1")} == {"finished"}
+ assert source_reader.get_by_workflow_execution("run-1") == []
assert {node.id for node in reader.get_by_workflow_execution("run-1", include_paused=True)} == {
"finished",
"paused",
}
+ assert {node.id for node in source_reader.get_by_workflow_execution("run-1", include_paused=True)} == {
+ "source-paused"
+ }
assert {node.id for node in reader.get_by_workflow_execution("run-1")} == {"finished"}
+ assert source_reader.get_by_workflow_execution("run-1") == []
+
+
+def test_run_reads_keep_workflow_tool_internals_in_their_source_scope(
+ monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
+) -> None:
+ caller = _repository(monkeypatch, sqlite_session_factory)
+ caller.save(_execution(execution_id="root", node_execution_id="root"))
+ for execution_id, tenant_id, app_id, root_app_id, run_id, origin in (
+ ("child", "tenant-1", "source-app", "app-1", "run-1", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL),
+ ("same-app", "tenant-1", "app-1", "app-1", "run-1", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL),
+ ("foreign-root", "tenant-1", "source-app", "app-2", "run-1", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL),
+ (
+ "foreign-tenant",
+ "tenant-2",
+ "source-app",
+ "app-1",
+ "run-1",
+ WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ ),
+ ("foreign-run", "tenant-1", "source-app", "app-1", "run-2", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL),
+ ("foreign-app", "tenant-1", "source-app", "app-1", "run-1", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN),
+ ):
+ _repository(
+ monkeypatch, sqlite_session_factory, tenant_id=tenant_id, app_id=app_id, triggered_from=origin
+ ).save(
+ _execution(
+ execution_id=execution_id,
+ node_execution_id=execution_id,
+ run_id=run_id,
+ process_data={WORKFLOW_TOOL_ROOT_APP_ID_KEY: root_app_id},
+ )
+ )
+ assert [node.id for node in caller.get_by_workflow_execution("run-1")] == ["root"]
+ source = caller.for_workflow_tool("source-app")
+ assert {node.id for node in source.get_by_workflow_execution("run-1")} == {"child", "foreign-root"}
def test_to_domain_model_loads_offloaded_storage(
diff --git a/api/tests/unit_tests/core/repositories/test_workflow_node_execution_batch.py b/api/tests/unit_tests/core/repositories/test_workflow_node_execution_batch.py
new file mode 100644
index 00000000000000..5fcfc1d7b347fd
--- /dev/null
+++ b/api/tests/unit_tests/core/repositories/test_workflow_node_execution_batch.py
@@ -0,0 +1,231 @@
+"""Batch cleanup persists exact records without losing ownership or stored data."""
+
+from datetime import datetime
+from uuid import uuid4
+
+import pytest
+from kombu.serialization import dumps, loads
+from sqlalchemy import Engine, event, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session, sessionmaker
+
+from core.repositories.celery_workflow_node_execution_repository import CeleryWorkflowNodeExecutionRepository
+from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
+from core.workflow.node_execution import WorkflowNodeExecution
+from extensions.logstore.aliyun_logstore import AliyunLogStore
+from extensions.logstore.repositories.logstore_workflow_node_execution_repository import (
+ LogstoreWorkflowNodeExecutionRepository,
+)
+from graphon.enums import WorkflowNodeExecutionStatus
+from models.enums import ExecutionOffLoadType
+from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload, WorkflowNodeExecutionTriggeredFrom
+from tasks import workflow_node_execution_tasks
+from tests.unit_tests.config_override import apply_config_overrides
+from tests.unit_tests.model_factories import make_account, make_upload_file
+
+
+def _execution() -> WorkflowNodeExecution:
+ return WorkflowNodeExecution(
+ id=str(uuid4()),
+ node_execution_id=str(uuid4()),
+ workflow_id="source-workflow",
+ workflow_execution_id="root-run",
+ triggered_from_workflow_id="caller-workflow",
+ triggered_from_node_execution_id="caller-execution",
+ index=1,
+ node_id="tool",
+ node_type="tool",
+ title="Nested tool",
+ created_at=datetime(2026, 9, 23),
+ process_data={"workflow_agent_binding_id": "binding"},
+ )
+
+
+@pytest.mark.parametrize("backend", ["sql", "celery", "logstore"])
+def test_batch_creates_missing_rows_and_updates_only_supplied_records(
+ sqlite_session_factory: sessionmaker[Session], sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch, backend: str
+) -> None:
+ expiring_session_factory = sessionmaker(sqlite_engine)
+ sql = SQLAlchemyWorkflowNodeExecutionRepository(
+ expiring_session_factory,
+ "tenant",
+ make_account(),
+ "source-app",
+ WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ )
+ existing, missing, untouched = (_execution() for _ in range(3))
+ sql.save(existing)
+ sql.save(untouched)
+ with sqlite_session_factory.begin() as session:
+ session.add(make_upload_file(file_id="offloaded-inputs", tenant_id="tenant"))
+ row = session.get(WorkflowNodeExecutionModel, existing.id)
+ assert row is not None
+ row.inputs = '{"large": "stored preview"}'
+ session.add(
+ WorkflowNodeExecutionOffload(
+ tenant_id="tenant",
+ app_id="source-app",
+ node_execution_id=existing.id,
+ type_=ExecutionOffLoadType.INPUTS,
+ file_id="offloaded-inputs",
+ )
+ )
+ existing.inputs = {"large": "full content restored from offload"}
+ for execution in (existing, missing):
+ execution.status = WorkflowNodeExecutionStatus.FAILED
+ execution.error = "workflow stopped"
+ execution.finished_at = datetime(2026, 9, 23, 0, 0, 1)
+ execution.process_data = {"partial": "result"}
+
+ queued: list[dict[str, object]] = []
+ logstore_records: list[dict[str, str]] = []
+ if backend == "celery":
+ task = workflow_node_execution_tasks.save_workflow_node_executions_task
+ monkeypatch.setattr(task, "delay", lambda **kwargs: queued.append(kwargs))
+ repository = CeleryWorkflowNodeExecutionRepository(
+ expiring_session_factory,
+ "tenant",
+ make_account(),
+ "source-app",
+ WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ )
+ elif backend == "logstore":
+ apply_config_overrides(monkeypatch, LOGSTORE_DUAL_WRITE_ENABLED=True)
+ monkeypatch.setattr(AliyunLogStore, "_instance", None)
+ monkeypatch.setattr(AliyunLogStore, "_initialized", False)
+ monkeypatch.setenv("ALIYUN_SLS_ENDPOINT", "localhost")
+ monkeypatch.setattr(AliyunLogStore, "_check_endpoint_connectivity", staticmethod(lambda *_: None))
+ monkeypatch.setattr(
+ AliyunLogStore,
+ "put_log",
+ lambda _self, _name, record: logstore_records.append(dict(record)),
+ )
+ repository = LogstoreWorkflowNodeExecutionRepository(
+ expiring_session_factory,
+ "tenant",
+ make_account(),
+ "source-app",
+ WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ )
+ else:
+ repository = sql
+
+ commits: list[None] = []
+ event.listen(sqlite_engine, "commit", lambda _connection: commits.append(None))
+ repository.save_many([existing, missing])
+ repository.save_many([])
+ if backend == "celery":
+ assert len(queued) == 1
+ assert commits == []
+ content_type, encoding, payload = dumps(queued[0], serializer="json")
+ workflow_node_execution_tasks.save_workflow_node_executions_task.run(
+ **loads(payload, content_type, encoding, accept={content_type})
+ )
+ assert len(commits) == 1
+
+ with sqlite_session_factory() as session:
+ rows = {
+ row.id: row
+ for row in session.scalars(
+ WorkflowNodeExecutionModel.preload_offload_data(select(WorkflowNodeExecutionModel))
+ )
+ }
+ assert set(rows) == {existing.id, missing.id, untouched.id}
+ assert rows[untouched.id].status == WorkflowNodeExecutionStatus.RUNNING
+ for execution in (existing, missing):
+ row = rows[execution.id]
+ assert row.status == WorkflowNodeExecutionStatus.FAILED
+ assert row.error == "workflow stopped"
+ assert row.finished_at == execution.finished_at
+ assert (row.tenant_id, row.app_id, row.triggered_from) == (
+ "tenant",
+ "source-app",
+ WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ )
+ assert (row.workflow_id, row.workflow_run_id) == ("source-workflow", "root-run")
+ assert (row.triggered_from_workflow_id, row.triggered_from_node_execution_id) == (
+ "caller-workflow",
+ "caller-execution",
+ )
+ assert rows[existing.id].process_data_dict == {"partial": "result", "workflow_agent_binding_id": "binding"}
+ assert rows[existing.id].inputs_dict == {"large": "stored preview"}
+ assert rows[existing.id].offload_data[0].file_id == "offloaded-inputs"
+ if backend == "logstore":
+ assert {row["id"] for row in logstore_records} == {existing.id, missing.id}
+ assert all(row["status"] == "failed" and row["log_version"] for row in logstore_records)
+ assert all(row["triggered_from_node_execution_id"] == "caller-execution" for row in logstore_records)
+
+
+@pytest.mark.parametrize("backend", ["sql", "celery"])
+@pytest.mark.parametrize("owner", ["tenant_id", "app_id", "triggered_from", "workflow_id", "workflow_run_id"])
+def test_owner_mismatch_rolls_back_the_whole_batch(
+ sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch, backend: str, owner: str
+) -> None:
+ repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ sqlite_session_factory, "tenant", make_account(), "source-app", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ )
+ valid, foreign = _execution(), _execution()
+ repository.save(valid)
+ repository.save(foreign)
+ with sqlite_session_factory.begin() as session:
+ row = session.get(WorkflowNodeExecutionModel, foreign.id)
+ assert row is not None
+ setattr(row, owner, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN if owner == "triggered_from" else "other")
+ for execution in (valid, foreign):
+ execution.status = WorkflowNodeExecutionStatus.FAILED
+ if backend == "celery":
+ task = workflow_node_execution_tasks.save_workflow_node_executions_task
+ monkeypatch.setattr(task, "retry", lambda **kwargs: kwargs["exc"])
+ with pytest.raises(IntegrityError):
+ task.run(
+ executions_data=[execution.model_dump() for execution in (valid, foreign)],
+ tenant_id="tenant",
+ app_id="source-app",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL.value,
+ creator_user_id="account",
+ creator_user_role="account",
+ )
+ else:
+ with pytest.raises(IntegrityError):
+ repository.save_many([valid, foreign])
+ with sqlite_session_factory() as session:
+ assert all(
+ row.status == WorkflowNodeExecutionStatus.RUNNING
+ for row in session.scalars(select(WorkflowNodeExecutionModel))
+ )
+
+
+@pytest.mark.parametrize(
+ "status",
+ [WorkflowNodeExecutionStatus.FAILED, WorkflowNodeExecutionStatus.PAUSED, WorkflowNodeExecutionStatus.RETRY],
+)
+def test_delayed_start_cannot_undo_completed_failure_cleanup(
+ sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch, status: WorkflowNodeExecutionStatus
+) -> None:
+ queued: list[dict[str, object]] = []
+ start_task = workflow_node_execution_tasks.save_workflow_node_execution_task
+ batch_task = workflow_node_execution_tasks.save_workflow_node_executions_task
+ monkeypatch.setattr(start_task, "delay", lambda **kwargs: queued.append(kwargs))
+ monkeypatch.setattr(batch_task, "delay", lambda **kwargs: queued.append(kwargs))
+ repository = CeleryWorkflowNodeExecutionRepository(
+ sqlite_session_factory, "tenant", make_account(), "source-app", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ )
+ execution = _execution()
+ repository.save(execution)
+ execution.status = status
+ execution.error = "workflow stopped"
+ execution.finished_at = datetime(2026, 9, 23, 0, 0, 1)
+ repository.save_many([execution])
+
+ for task, kwargs in zip((batch_task, start_task), reversed(queued), strict=True):
+ content_type, encoding, payload = dumps(kwargs, serializer="json")
+ task.run(**loads(payload, content_type, encoding, accept={content_type}))
+
+ with sqlite_session_factory() as session:
+ row = session.get(WorkflowNodeExecutionModel, execution.id)
+ assert row is not None
+ if status == WorkflowNodeExecutionStatus.FAILED:
+ assert (row.status, row.error, row.finished_at) == (status, execution.error, execution.finished_at)
+ else:
+ assert row.status == WorkflowNodeExecutionStatus.RUNNING
+ assert row.finished_at is None
diff --git a/api/tests/unit_tests/core/repositories/test_workflow_node_execution_callers.py b/api/tests/unit_tests/core/repositories/test_workflow_node_execution_callers.py
new file mode 100644
index 00000000000000..4f51cce623cd7c
--- /dev/null
+++ b/api/tests/unit_tests/core/repositories/test_workflow_node_execution_callers.py
@@ -0,0 +1,163 @@
+"""Caller identity survives storage updates independently of node payloads."""
+
+from datetime import datetime
+from uuid import uuid4
+
+import pytest
+from kombu.serialization import dumps, loads
+from sqlalchemy.orm import Session, sessionmaker
+
+from core.repositories.celery_workflow_node_execution_repository import CeleryWorkflowNodeExecutionRepository
+from core.repositories.factory import WorkflowNodeExecutionRepository
+from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
+from core.workflow.node_execution import WorkflowNodeExecution
+from extensions.logstore.aliyun_logstore import AliyunLogStore
+from extensions.logstore.repositories.logstore_workflow_node_execution_repository import (
+ LogstoreWorkflowNodeExecutionRepository,
+)
+from graphon.enums import WorkflowNodeExecutionStatus
+from models.model import UploadFile
+from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
+from repositories.factory import DifyAPIRepositoryFactory
+from tasks.workflow_node_execution_tasks import save_workflow_node_execution_task
+from tests.unit_tests.config_override import apply_config_overrides
+from tests.unit_tests.model_factories import make_account, make_upload_file
+
+
+@pytest.mark.parametrize("backend", ["sql", "celery", "logstore"])
+def test_caller_locators_survive_storage_round_trip_and_updates(
+ sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch, backend: str
+) -> None:
+ apply_config_overrides(monkeypatch, LOGSTORE_DUAL_WRITE_ENABLED=False)
+ logstore_records: list[dict[str, str]] = []
+ sql = SQLAlchemyWorkflowNodeExecutionRepository(
+ sqlite_session_factory, "tenant", make_account(), "source-app", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ )
+ execution = WorkflowNodeExecution(
+ id=str(uuid4()),
+ node_execution_id="child-execution",
+ workflow_id=str(uuid4()),
+ workflow_execution_id="root-run",
+ triggered_from_workflow_id=str(uuid4()),
+ triggered_from_node_execution_id="call/" + "x" * 250,
+ index=1,
+ node_id="reused-canvas-node",
+ node_type="tool",
+ title="Nested Tool",
+ created_at=datetime(2026, 9, 23),
+ )
+ repository: WorkflowNodeExecutionRepository
+ if backend == "celery":
+
+ def deliver(**kwargs: object) -> None:
+ content_type, encoding, payload = dumps(kwargs, serializer="json")
+ save_workflow_node_execution_task.run(**loads(payload, content_type, encoding, accept={content_type}))
+
+ monkeypatch.setattr(save_workflow_node_execution_task, "delay", deliver)
+ repository = CeleryWorkflowNodeExecutionRepository(
+ sqlite_session_factory,
+ "tenant",
+ make_account(),
+ "source-app",
+ WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ )
+ elif backend == "logstore":
+ monkeypatch.setattr(AliyunLogStore, "_instance", None)
+ monkeypatch.setattr(AliyunLogStore, "_initialized", False)
+ monkeypatch.setenv("ALIYUN_SLS_ENDPOINT", "localhost")
+ monkeypatch.setattr(AliyunLogStore, "_check_endpoint_connectivity", staticmethod(lambda *_: None))
+ monkeypatch.setattr(
+ AliyunLogStore,
+ "put_log",
+ lambda _self, _name, record: logstore_records.append(dict(record)),
+ )
+ repository = LogstoreWorkflowNodeExecutionRepository(
+ sqlite_session_factory,
+ "tenant",
+ make_account(),
+ "source-app",
+ WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ )
+ else:
+ repository = sql
+
+ repository.save(execution)
+ execution.status = WorkflowNodeExecutionStatus.SUCCEEDED
+ execution.process_data = {"result": "no caller identifiers in this payload"}
+ execution.finished_at = datetime(2026, 9, 23, 0, 0, 1)
+ repository.save(execution)
+ repository.save_execution_data(execution)
+
+ if backend == "logstore":
+ row = logstore_records[-1]
+ monkeypatch.setattr(AliyunLogStore, "execute_sql", lambda *_args, **_kwargs: [row])
+ restored = repository.get_by_workflow_execution("root-run")[0]
+ apply_config_overrides(
+ monkeypatch,
+ API_WORKFLOW_NODE_EXECUTION_REPOSITORY=(
+ "extensions.logstore.repositories.logstore_api_workflow_node_execution_repository."
+ "LogstoreAPIWorkflowNodeExecutionRepository"
+ ),
+ )
+ api_repository = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(sqlite_session_factory)
+ model = api_repository.get_execution_by_id(execution.id, "tenant")
+ else:
+ restored = sql.get_by_workflow_execution("root-run")[0]
+ with sqlite_session_factory() as session:
+ model = session.get(WorkflowNodeExecutionModel, execution.id)
+
+ assert model is not None
+ assert isinstance(restored, WorkflowNodeExecution)
+ for record in (restored, model):
+ assert record.triggered_from_workflow_id == execution.triggered_from_workflow_id
+ assert record.triggered_from_node_execution_id == execution.triggered_from_node_execution_id
+ assert record.workflow_id == execution.workflow_id
+ assert model.app_id == "source-app"
+ assert model.workflow_run_id == "root-run"
+ assert restored.process_data == execution.process_data
+
+
+def test_sql_offload_preserves_caller_locators(
+ sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch
+) -> None:
+ apply_config_overrides(
+ monkeypatch, WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE=32, WORKFLOW_VARIABLE_TRUNCATION_STRING_LENGTH=8
+ )
+ repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ sqlite_session_factory, "tenant", make_account(), "source-app", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ )
+ stored_content: dict[str, bytes] = {}
+
+ def upload_file(*, content: bytes, **_kwargs: object) -> UploadFile:
+ file = make_upload_file(file_id=str(uuid4()), key="process-data", tenant_id="tenant")
+ with sqlite_session_factory.begin() as session:
+ session.add(file)
+ stored_content[file.key] = content
+ return file
+
+ monkeypatch.setattr(repository._file_service, "upload_file", upload_file)
+ monkeypatch.setattr(
+ "core.repositories.sqlalchemy_workflow_node_execution_repository.storage.load", stored_content.__getitem__
+ )
+ execution = WorkflowNodeExecution(
+ id=str(uuid4()),
+ workflow_id=str(uuid4()),
+ workflow_execution_id="root-run",
+ triggered_from_workflow_id=str(uuid4()),
+ triggered_from_node_execution_id="immediate-tool-execution",
+ index=1,
+ node_id="node",
+ node_type="code",
+ title="Code",
+ created_at=datetime(2026, 9, 23),
+ process_data={"large": "x" * 1000},
+ )
+ repository.save(execution)
+ repository.save_execution_data(execution)
+
+ restored = repository.get_by_workflow_execution("root-run")[0]
+ assert isinstance(restored, WorkflowNodeExecution)
+ assert restored.process_data_truncated
+ assert restored.process_data == execution.process_data
+ assert restored.triggered_from_workflow_id == execution.triggered_from_workflow_id
+ assert restored.triggered_from_node_execution_id == "immediate-tool-execution"
diff --git a/api/tests/unit_tests/core/tools/test_tool_manager.py b/api/tests/unit_tests/core/tools/test_tool_manager.py
index 6e2737eea2d903..0bd4bc75d437f1 100644
--- a/api/tests/unit_tests/core/tools/test_tool_manager.py
+++ b/api/tests/unit_tests/core/tools/test_tool_manager.py
@@ -609,7 +609,7 @@ def test_get_tool_runtime_workflow_path(monkeypatch: pytest.MonkeyPatch, tool_da
workflow_app_id=workflow_provider.app_id,
workflow_as_tool_id=provider_id,
version=workflow_provider.version,
- workflow_entities={},
+ workflow_id="00000000-0000-0000-0000-000000000003",
workflow_call_depth=0,
entity=ToolEntity(
identity=ToolIdentity(author="test", name="wf", label=I18nObject(en_US="Workflow"), provider=provider_id),
diff --git a/api/tests/unit_tests/core/tools/utils/test_workflow_configuration_sync.py b/api/tests/unit_tests/core/tools/utils/test_workflow_configuration_sync.py
index 43f3fbd5c99bf3..f1de66fd64410f 100644
--- a/api/tests/unit_tests/core/tools/utils/test_workflow_configuration_sync.py
+++ b/api/tests/unit_tests/core/tools/utils/test_workflow_configuration_sync.py
@@ -1,40 +1,10 @@
import pytest
from core.tools.entities.tool_entities import ToolParameter, WorkflowToolParameterConfiguration
-from core.tools.errors import WorkflowToolHumanInputNotSupportedError
from core.tools.utils.workflow_configuration_sync import WorkflowToolConfigurationUtils
from graphon.variables.input_entities import VariableEntity, VariableEntityType
-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"
-
-
def test_get_workflow_graph_variables_and_outputs():
graph = {
"nodes": [
diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py
index efac03fa7ae8b1..a034d5f72fc1d6 100644
--- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py
+++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py
@@ -125,7 +125,7 @@ def _workflow_tool(name: str = "workflow_tool", *, tenant_id: str | None = None)
),
runtime=ToolRuntime(tenant_id=app.tenant_id),
workflow_app_id=app.id,
- workflow_entities={"app": app, "workflow": workflow},
+ workflow_id=workflow.id,
version="1",
workflow_call_depth=0,
)
@@ -192,7 +192,7 @@ def test_get_db_provider_tool_builds_entity(database_session: Session):
return_value=outputs,
),
):
- tool = controller._get_db_provider_tool(db_provider, app, session=database_session, user=user)
+ tool = controller._get_db_provider_tool(db_provider, session=database_session, user=user)
assert tool.entity.identity.name == "workflow_tool"
# "json" output is reserved for ToolInvokeMessage.VariableMessage and filtered out.
@@ -245,7 +245,7 @@ def test_from_db_builds_controller(database_session: Session):
assert built.provider_id == db_provider.id
assert built.tools is not None
assert built.tools[0].workflow_app_id == app.id
- assert built.tools[0].workflow_entities["workflow"].id == workflow.id
+ assert built.tools[0].workflow_id == workflow.id
def test_get_tools_returns_empty_when_provider_missing(database_session: Session):
diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py
index ca8c5192bd27ff..1985a32889a4b5 100644
--- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py
+++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py
@@ -26,6 +26,7 @@
from core.tools.tool_manager import ToolManager
from core.tools.workflow_as_tool import tool as workflow_tool_module
from core.tools.workflow_as_tool.tool import WorkflowTool
+from graphon.enums import WorkflowExecutionStatus
from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.base import TypeBase
@@ -178,7 +179,7 @@ def _build_tool(*, tenant_id: str = "test_tool", workflow_app_id: str = "app-1",
return WorkflowTool(
execution_driver=WorkflowRunAgg.run,
workflow_app_id=workflow_app_id,
- workflow_entities={},
+ workflow_id="workflow-1",
workflow_as_tool_id="wf-tool-1",
version=version,
workflow_call_depth=1,
@@ -283,11 +284,36 @@ def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_fiel
assert exc_info.value.args == ("oops",)
-def test_workflow_tool_does_not_use_pause_state_config(
+def test_workflow_tool_rejects_paused_generator_result(
+ monkeypatch: pytest.MonkeyPatch,
+ sqlite_session: Session,
+):
+ tool = _build_tool()
+ user = make_account(account_id="test_user")
+ generate, generate_calls = _record_calls(
+ return_value={
+ "data": {
+ "status": WorkflowExecutionStatus.PAUSED,
+ "outputs": {},
+ }
+ }
+ )
+
+ monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None)
+ monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None)
+ monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: user)
+ monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate)
+
+ with pytest.raises(ToolInvokeError, match="requires Engine-managed container execution"):
+ list(tool.invoke(sqlite_session, "test_user", {}))
+
+ assert len(generate_calls) == 1
+
+
+def test_legacy_tool_disables_human_input(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
):
- """Ensure pause_state_config is passed as None."""
tool = _build_tool()
monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None)
@@ -302,8 +328,8 @@ def test_workflow_tool_does_not_use_pause_state_config(
list(tool.invoke(sqlite_session, "test_user", {}))
call_kwargs = generate_calls[-1]
- assert "pause_state_config" in call_kwargs
assert call_kwargs["pause_state_config"] is None
+ assert call_kwargs["allow_human_input"] is False
def test_workflow_tool_passes_parent_trace_context_from_runtime(
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_workflow_tool_observability.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_workflow_tool_observability.py
new file mode 100644
index 00000000000000..563b40824d0ba4
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_workflow_tool_observability.py
@@ -0,0 +1,215 @@
+from collections.abc import Generator
+from contextlib import AbstractContextManager
+from contextvars import ContextVar
+from functools import partial
+from threading import Event, get_ident
+from types import TracebackType
+from typing import override
+
+import pytest
+from flask import current_app, g
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+from opentelemetry.trace import get_current_span
+
+from context import capture_current_context
+from context.flask_app_context import capture_flask_context
+from core.app.layers import execution_context_layer as module
+from core.app.workflow.file_runtime import init_app
+from core.app.workflow.layers.observability import ObservabilityLayer
+from core.workflow.workflow_entry import WorkflowEntry
+from core.workflow.workflow_tool_container_handler import WorkflowToolContainerHandler
+from dify_app import DifyApp
+from graphon.engine import Engine
+from graphon.engine.layer import Layer
+from graphon.engine_events import GraphRunSucceededEvent, NodeEvent, NodeRunStreamChunkEvent
+from graphon.entities.base_node_data import BaseNodeData
+from graphon.enums import BuiltinNodeTypes
+from graphon.node_events.base import NodeEventPayload
+from graphon.nodes.base.node import Node
+from graphon.nodes.container_effects import ContainerAwaitRequest, ContainerRunResult
+from graphon.runtime import RuntimeState, VariablePool
+from tests.unit_tests.config_override import apply_config_overrides
+from tests.unit_tests.core.workflow.test_workflow_tool_container import (
+ _outer_graph,
+ _workflow_tool_node,
+ _workflow_tool_source,
+)
+
+
+def test_workflow_tool_tracing_survives_worker_handoff(
+ monkeypatch: pytest.MonkeyPatch,
+ caplog: pytest.LogCaptureFixture,
+ tracer_provider_with_memory_exporter: TracerProvider,
+ memory_span_exporter: InMemorySpanExporter,
+) -> None:
+ apply_config_overrides(monkeypatch, ENABLE_OTEL=True)
+ tool_started_on: list[int] = []
+ tool_finished_on: list[int] = []
+ completed = Event()
+
+ class HandoffLayer(Layer):
+ @override
+ def node_run_context(
+ self, node: Node[BaseNodeData], *, parent_execution_id: str | None = None
+ ) -> AbstractContextManager[None]:
+ return HandoffContext()
+
+ @override
+ def on_node_run_start(self, node: Node[BaseNodeData]) -> None:
+ if node.id == "tool":
+ tool_started_on.append(get_ident())
+
+ @override
+ def on_node_run_end(
+ self, node: Node[BaseNodeData], error: Exception | None, result_event: NodeEvent | None = None
+ ) -> None:
+ if node.id == "tool":
+ tool_finished_on.append(get_ident())
+ completed.set()
+
+ tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
+ with tracer.start_as_current_span("workflow") as root_span:
+ captured_context = capture_current_context()
+
+ class HandoffContext(AbstractContextManager[None]):
+ @override
+ def __enter__(self) -> None:
+ captured_context.__enter__()
+
+ @override
+ def __exit__(
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, traceback: TracebackType | None
+ ) -> None:
+ captured_context.__exit__(exc_type, exc, traceback)
+ if tool_started_on == [get_ident()] and not completed.is_set():
+ # Keep the starting worker occupied after suspension so another
+ # worker must execute the child graph and resume the Tool.
+ assert completed.wait(10), "Workflow Tool did not resume on the available worker"
+
+ state = RuntimeState(workflow_id="outer-workflow", variable_pool=VariablePool(), start_at=1)
+ tool, _, _ = _workflow_tool_node(state)
+ original_resume = tool._resume_container_events
+
+ def instrument_resume(*, result: ContainerRunResult) -> Generator[NodeEventPayload | ContainerAwaitRequest]:
+ with tracer.start_as_current_span("resume-operation"):
+ yield from original_resume(result=result)
+
+ monkeypatch.setattr(tool, "_resume_container_events", instrument_resume)
+ source = _workflow_tool_source()
+ sources = {source.workflow_id: source}
+ engine = Engine(
+ graph=_outer_graph(tool),
+ runtime_state=state,
+ workers=2,
+ container_handler_factories=(partial(WorkflowToolContainerHandler, sources=sources),),
+ )
+ engine.add_layer(HandoffLayer())
+ engine.add_layer(ObservabilityLayer())
+
+ events = list(engine.run())
+
+ assert isinstance(events[-1], GraphRunSucceededEvent)
+ assert len(tool_started_on) == len(tool_finished_on) == 1
+ assert tool_started_on != tool_finished_on
+ spans = memory_span_exporter.get_finished_spans()
+ tool_spans = [span for span in spans if (span.attributes or {}).get("node.id") == "tool"]
+ assert len(tool_spans) == 1
+ tool_span = tool_spans[0]
+ assert tool_span.parent == root_span.get_span_context()
+ resumed_operations = [span for span in spans if span.name == "resume-operation"]
+ assert len(resumed_operations) == 1
+ assert resumed_operations[0].parent == tool_span.context
+ assert "was created in a different Context" not in caplog.text
+ children = [span for span in spans if (span.attributes or {}).get("node.id") in {"source-start", "source-end"}]
+ assert len(children) == 2
+ assert all(span.parent == tool_span.context for span in children)
+
+
+def test_standalone_node_uses_worker_scoped_tracing_context(
+ monkeypatch: pytest.MonkeyPatch,
+ tracer_provider_with_memory_exporter: TracerProvider,
+ memory_span_exporter: InMemorySpanExporter,
+) -> None:
+ apply_config_overrides(monkeypatch, ENABLE_OTEL=True)
+ tool, _, _ = _workflow_tool_node()
+ node = _outer_graph(tool).root_node
+ tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
+ with tracer.start_as_current_span("standalone") as parent:
+ events = list(WorkflowEntry._run_node_with_layers(node))
+ assert get_current_span() is parent
+
+ assert isinstance(events[-1], NodeEvent)
+ assert events[-1].node_run_result.status == "succeeded"
+ node_spans = [span for span in memory_span_exporter.get_finished_spans() if span.name == node.title]
+ assert len(node_spans) == 1
+ assert node_spans[0].parent == parent.get_span_context()
+
+
+def test_interleaved_snippets_restore_caller_flask_context(
+ monkeypatch: pytest.MonkeyPatch,
+ tracer_provider_with_memory_exporter: TracerProvider,
+ memory_span_exporter: InMemorySpanExporter,
+) -> None:
+ apply_config_overrides(monkeypatch, ENABLE_OTEL=True)
+ monkeypatch.setattr(module, "capture_current_context", capture_flask_context)
+ request_id: ContextVar[str] = ContextVar("snippet_request_id")
+ tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
+ parents = []
+ snippets = []
+ observed = []
+ for name in ("first", "second"):
+ app = DifyApp(name)
+ init_app(app)
+ with app.app_context(), tracer.start_as_current_span(name) as parent:
+ parents.append(parent.get_span_context())
+ g._login_user = name
+ request_id.set(name)
+ tool, _, _ = _workflow_tool_node()
+ node = _outer_graph(tool).root_node
+
+ def run_node() -> Generator[NodeRunStreamChunkEvent]:
+ observed.append((current_app.name, g._login_user, request_id.get()))
+ yield NodeRunStreamChunkEvent(
+ id="snippet-execution",
+ node_id="outer-start",
+ node_type=BuiltinNodeTypes.START,
+ selector=["outer-start", "text"],
+ chunk="part",
+ is_final=False,
+ )
+ observed.append((current_app.name, g._login_user, request_id.get()))
+ yield NodeRunStreamChunkEvent(
+ id="snippet-execution",
+ node_id="outer-start",
+ node_type=BuiltinNodeTypes.START,
+ selector=["outer-start", "text"],
+ chunk="part",
+ is_final=False,
+ )
+
+ monkeypatch.setattr(node, "run", run_node)
+ snippets.append(WorkflowEntry._run_node_with_layers(node))
+
+ caller = DifyApp("caller")
+ init_app(caller)
+ with caller.app_context(), tracer.start_as_current_span("caller") as caller_span:
+ g._login_user = "caller"
+ request_id.set("caller")
+ for _ in range(2):
+ for snippet in snippets:
+ next(snippet)
+ assert current_app == caller
+ assert g._login_user == request_id.get() == "caller"
+ assert get_current_span() is caller_span
+ for snippet in snippets:
+ snippet.close()
+ assert current_app == caller
+ assert g._login_user == request_id.get() == "caller"
+ assert get_current_span() is caller_span
+
+ assert observed == [(name, name, name) for name in ("first", "second", "first", "second")]
+ spans = memory_span_exporter.get_finished_spans()
+ node_spans = [span for span in spans if (span.attributes or {}).get("node.id") == "outer-start"]
+ assert len(node_spans) == 2
+ assert [span.parent for span in node_spans] == parents
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py
index c3114fb47b9ab1..48296df3a051c2 100644
--- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py
@@ -9,6 +9,7 @@
from core.workflow.human_input_adapter import adapt_node_config_for_graph
from core.workflow.node_factory import DifyNodeFactory
+from graphon.entities.base_node_data import BaseNodeData
from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter
from graphon.enums import BuiltinNodeTypes, NodeType
from graphon.nodes.base.node import Node
@@ -83,6 +84,23 @@ def with_runtime_state(self, runtime_state: "RuntimeState") -> "MockNodeFactory"
mock_config=self.mock_config,
)
+ @override
+ def _resolve_node_class_for_factory(
+ self,
+ *,
+ node_type: NodeType,
+ node_version: str,
+ node_data: BaseNodeData,
+ ) -> type[Node]:
+ mock_class = self._mock_node_types.get(node_type)
+ if mock_class is not None:
+ return mock_class
+ return super()._resolve_node_class_for_factory(
+ node_type=node_type,
+ node_version=node_version,
+ node_data=node_data,
+ )
+
def create_node(self, node_config: dict[str, Any] | NodeConfigDict) -> Node:
"""
Create a node instance, using mock implementations for third-party service nodes.
diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py
index 10750315ccfa36..283c46d974fa89 100644
--- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py
+++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py
@@ -22,6 +22,7 @@
)
from dify_agent.protocol.snapshot import SessionSnapshot
from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
+from sqlalchemy.orm import Session
from clients.agent_backend import (
AgentBackendInternalEventType,
@@ -58,7 +59,15 @@
from graphon.node_events import StreamChunkEvent, StreamCompletedEvent
from graphon.runtime import InitParams, RuntimeState
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
-from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
+from models.agent import (
+ Agent,
+ AgentConfigSnapshot,
+ AgentScope,
+ AgentSource,
+ AgentWorkspaceOwnerType,
+ WorkflowAgentBindingType,
+ WorkflowAgentNodeBinding,
+)
from models.agent_config_entities import (
AgentSoulConfig,
AgentSoulModelConfig,
@@ -406,6 +415,7 @@ def _node(
agent_backend_client: FakeAgentBackendRunClient | None = None,
binding_resolver: FakeBindingResolver | None = None,
runtime_request_builder: WorkflowAgentRuntimeRequestBuilder | None = None,
+ workflow_tool_invocation_id: str | None = None,
error_strategy: ErrorStrategy | None = None,
) -> DifyAgentNode:
graph_init_params = InitParams(
@@ -418,6 +428,7 @@ def _node(
user_id="user-1",
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
+ workflow_tool_invocation_id=workflow_tool_invocation_id,
)
},
call_depth=0,
@@ -596,6 +607,42 @@ def test_agent_node_resume_resolves_the_generation_from_the_persisted_execution(
assert binding_resolver.calls[0]["conversation_id"] == "conversation-1"
+def test_agent_node_workflow_tool_does_not_resolve_outer_conversation_participant(sqlite_session: Session) -> None:
+ binding_resolver = FakeBindingResolver()
+ binding_resolver.agent.scope = AgentScope.WORKFLOW_ONLY
+ binding_resolver.agent.source = AgentSource.WORKFLOW
+ binding_resolver.binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT
+ binding_resolver.binding.workflow_version = "draft"
+ sqlite_session.add_all([binding_resolver.binding, binding_resolver.agent, binding_resolver.snapshot])
+ sqlite_session.commit()
+ store = FakeSessionStore()
+ node = _node(
+ binding_resolver=binding_resolver,
+ session_store=store,
+ workflow_tool_invocation_id="tool-call-1",
+ )
+ with (
+ patch.object(binding_resolver, "resolve", wraps=WorkflowAgentBindingResolver().resolve),
+ patch.object(
+ WorkflowAgentWorkspaceStore,
+ "load_active_participant",
+ side_effect=AssertionError("Workflow Tool must not select an outer Chatflow participant"),
+ ) as load_participant,
+ ):
+ events = list(node._run())
+
+ assert cast(StreamCompletedEvent, events[0]).node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
+ load_participant.assert_not_called()
+ assert store.existing_scope_lookups[0]["conversation_id"] == "conversation-1"
+ assert store.existing_scope_lookups[0]["workflow_tool_invocation_id"] == "tool-call-1"
+ scope = store.resolved_scopes[0]
+ assert scope.agent_config_snapshot_id == "snapshot-1"
+ assert scope.conversation_id == "conversation-1"
+ assert scope.workflow_tool_invocation_id == "tool-call-1"
+ assert scope.workspace_owner.owner_type == AgentWorkspaceOwnerType.WORKFLOW_RUN
+ assert scope.workspace_owner.owner_id == "workflow-run-1"
+
+
def test_agent_node_maps_persisted_participant_lookup_error_to_node_failure() -> None:
class _UnavailableParticipantStore(FakeSessionStore):
def load_existing_node_execution_scope(self, **kwargs: object) -> WorkflowAgentSessionScope | None:
diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py
index 9ace4b35338428..8f79940188b802 100644
--- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py
+++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py
@@ -1,20 +1,24 @@
import json
from collections.abc import Iterator
from contextlib import nullcontext
+from dataclasses import replace
from types import SimpleNamespace
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import pytest
+from dify_agent.protocol import CreateExecutionBindingRequest, CreateExecutionBindingResponse
from dify_agent.protocol.snapshot import SessionSnapshot
+from dify_agent.runtime_backend.errors import SharedWorkspaceUnsupportedError
from sqlalchemy import Engine, event, func, select
from sqlalchemy.orm import Session, sessionmaker
+from core.workflow.node_execution_process_data import WORKFLOW_TOOL_ROOT_APP_ID_KEY
from core.workflow.nodes.agent_v2.session_store import (
WorkflowAgentSessionScope,
WorkflowAgentWorkspaceStore,
resolve_workflow_agent_workspace_owner_scope,
)
-from graphon.enums import WorkflowNodeExecutionStatus
+from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus, WorkflowType
from models.agent import (
AgentConfigVersionKind,
AgentHomeSnapshot,
@@ -23,9 +27,16 @@
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
)
-from models.enums import CreatorUserRole
-from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
+from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
+from models.model import App, AppMode, IconType
+from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowRun
+from repositories.app.agent_sandbox_repository import AgentSandboxRepository
+from repositories.sqlalchemy_api_workflow_node_execution_repository import (
+ DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
+)
+from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentWorkspaceService
+from services.app_service import AppService
def _scope() -> WorkflowAgentSessionScope:
@@ -58,7 +69,7 @@ def _execution_row(
workflow_run_id=workflow_run_id,
index=1,
predecessor_node_id=None,
- node_execution_id="node-execution-1",
+ node_execution_id=execution_id,
node_id="node-1",
node_type="agent",
title="Agent",
@@ -250,6 +261,136 @@ def test_conversation_scope_reuses_existing_binding_on_later_turn(
assert sqlite_session.scalar(select(func.count()).select_from(AgentWorkspaceBinding)) == 1
+@pytest.mark.parametrize("overlap", [False, True], ids=["sequential", "overlapping-allocation"])
+def test_workflow_tool_calls_allocate_distinct_agent_workspaces(
+ monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, overlap: bool
+) -> None:
+ first = _execution_row()
+ second = _execution_row(execution_id="execution-2")
+ first.triggered_from = second.triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ sqlite_session.add_all([first, second])
+ sqlite_session.commit()
+ scopes = [
+ replace(_scope(), node_execution_id=execution.id, workflow_tool_invocation_id=invocation_id)
+ for execution, invocation_id in ((first, "tool-call-1"), (second, "tool-call-2"))
+ ]
+ store = WorkflowAgentWorkspaceStore()
+ client = MagicMock()
+ allocation_requests: list[CreateExecutionBindingRequest] = []
+
+ def allocate(request: CreateExecutionBindingRequest) -> CreateExecutionBindingResponse:
+ allocation_requests.append(request)
+ # Match providers such as E2B and Enterprise, which cannot create a new
+ # participant attached to an existing Workspace.
+ if request.existing_workspace_ref is not None:
+ raise SharedWorkspaceUnsupportedError("cannot attach to an existing Workspace")
+ if overlap and len(allocation_requests) == 1:
+ # The second caller commits after the first resolved its owner, but
+ # before the first allocation returns. This reproduces the race
+ # without relying on thread timing or SQLite write-lock behavior.
+ store.load_or_create_node_execution_session(scopes[1], home_snapshot_id=None)
+ return CreateExecutionBindingResponse(
+ binding_ref=f"binding-{request.binding_id}", workspace_ref=f"workspace-{request.workspace_id}"
+ )
+
+ client.create_execution_binding_sync.side_effect = allocate
+ monkeypatch.setattr(AgentWorkspaceService, "_client", lambda: nullcontext(client))
+ store.load_or_create_node_execution_session(scopes[0], home_snapshot_id=None)
+ if not overlap:
+ store.load_or_create_node_execution_session(scopes[1], home_snapshot_id=None)
+
+ sqlite_session.expire_all()
+ workspaces = list(sqlite_session.scalars(select(AgentWorkspace)))
+ assert len(workspaces) == 2
+ assert len({workspace.owner_scope_key for workspace in workspaces}) == 2
+ assert {workspace.owner_id for workspace in workspaces} == {"run-1"}
+ bindings = list(sqlite_session.scalars(select(AgentWorkspaceBinding)))
+ assert len({binding.workspace_id for binding in bindings}) == 2
+ assert first.agent_workspace_binding_id != second.agent_workspace_binding_id
+
+
+@pytest.mark.parametrize("conversation_id", [None, "conversation-1"])
+def test_workflow_tool_agent_workspace_identity_survives_resume(
+ monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, conversation_id: str | None
+) -> None:
+ execution = _execution_row()
+ execution.triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ sqlite_session.add(execution)
+ sqlite_session.commit()
+ client = _install_backend_client(monkeypatch)
+ scope = replace(_scope(), conversation_id=conversation_id, workflow_tool_invocation_id="tool-call-1")
+ assert scope.workspace_owner.owner_type is AgentWorkspaceOwnerType.WORKFLOW_RUN
+ assert scope.workspace_owner.owner_id == "run-1"
+ stored = WorkflowAgentWorkspaceStore().load_or_create_node_execution_session(scope, home_snapshot_id=None)
+ snapshot = SessionSnapshot(layers={})
+ WorkflowAgentWorkspaceStore().save_active_snapshot(
+ scope=scope,
+ binding_id=stored.binding_id,
+ snapshot=snapshot,
+ )
+
+ resumed_store = WorkflowAgentWorkspaceStore()
+ restored_scope = resumed_store.load_existing_node_execution_scope(
+ tenant_id=scope.tenant_id,
+ app_id=scope.app_id,
+ workflow_id=scope.workflow_id,
+ workflow_run_id=scope.workflow_run_id,
+ node_id=scope.node_id,
+ node_execution_id=scope.node_execution_id,
+ conversation_id=conversation_id,
+ workflow_tool_invocation_id="tool-call-1",
+ )
+ assert restored_scope is not None
+ assert restored_scope == scope
+ resumed = resumed_store.load_or_create_node_execution_session(restored_scope, home_snapshot_id=None)
+ assert (resumed.workspace_id, resumed.binding_id) == (stored.workspace_id, stored.binding_id)
+ assert resumed.session_snapshot == snapshot
+ client.create_execution_binding_sync.assert_called_once()
+ sqlite_session.expire_all()
+ inspected = AgentSandboxRepository._resolve_workflow_binding(
+ tenant_id=scope.tenant_id,
+ app_id=scope.app_id,
+ workflow_run_id="run-1",
+ node_id=scope.node_id,
+ node_execution_id=scope.node_execution_id,
+ session=sqlite_session,
+ )
+ assert inspected.backend_binding_ref == stored.backend_binding_ref
+
+ with pytest.raises(AgentWorkspaceNotFoundError, match="caller invocation does not match"):
+ resumed_store.load_existing_node_execution_scope(
+ tenant_id=scope.tenant_id,
+ app_id=scope.app_id,
+ workflow_id=scope.workflow_id,
+ workflow_run_id=scope.workflow_run_id,
+ node_id=scope.node_id,
+ node_execution_id=scope.node_execution_id,
+ workflow_tool_invocation_id="another-tool-call",
+ )
+
+
+def test_agent_executions_within_one_workflow_tool_invocation_keep_workspace_sharing(
+ monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
+) -> None:
+ first = _execution_row()
+ second = _execution_row(execution_id="iteration-2-execution")
+ first.triggered_from = second.triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ sqlite_session.add_all([first, second])
+ sqlite_session.commit()
+ _install_backend_client(monkeypatch)
+ store = WorkflowAgentWorkspaceStore()
+ scope = replace(_scope(), workflow_tool_invocation_id="tool-call-1")
+
+ first_session = store.load_or_create_node_execution_session(scope, home_snapshot_id=None)
+ second_session = store.load_or_create_node_execution_session(
+ replace(scope, node_execution_id=second.id), home_snapshot_id=None
+ )
+
+ assert first_session.workspace_id == second_session.workspace_id
+ assert first_session.binding_id != second_session.binding_id
+ assert sqlite_session.scalar(select(func.count()).select_from(AgentWorkspace)) == 1
+
+
def test_load_existing_scope_reads_the_generation_from_the_persisted_binding(sqlite_session: Session) -> None:
sqlite_session.add_all(
[
@@ -398,16 +539,17 @@ def test_load_existing_scope_waits_for_caller_row_to_become_visible(
sqlite_session_factory: sessionmaker[Session],
executed_statements: list[str],
) -> None:
- sleep = MagicMock()
+ sleep_count = 0
def make_caller_visible(_seconds: float) -> None:
- if sleep.call_count == 2:
+ nonlocal sleep_count
+ sleep_count += 1
+ if sleep_count == 2:
with sqlite_session_factory() as observer:
observer.add(_execution_row())
observer.commit()
- sleep.side_effect = make_caller_visible
- monkeypatch.setattr("core.workflow.nodes.agent_v2.session_store.time.sleep", sleep)
+ monkeypatch.setattr("core.workflow.nodes.agent_v2.session_store.time.sleep", make_caller_visible)
scope = WorkflowAgentWorkspaceStore().load_existing_node_execution_scope(
tenant_id="tenant-1",
@@ -420,7 +562,7 @@ def make_caller_visible(_seconds: float) -> None:
assert scope is None
assert len(_execution_selects(executed_statements)) == 3
- assert sleep.call_count == 2
+ assert sleep_count == 2
def test_save_snapshot_targets_binding(sqlite_session: Session) -> None:
@@ -541,3 +683,163 @@ def test_conversation_scope_first_turn_creates_binding_without_existing_workspac
request = client.create_execution_binding_sync.call_args.args[0]
assert request.existing_workspace_ref is None
+
+
+@pytest.mark.parametrize("caller_app", ["app-1", "unrelated-app"])
+@pytest.mark.parametrize("root_marker", [None, "app-1", "mismatched-app"])
+@pytest.mark.parametrize("persist_run", [False, True])
+def test_retire_workflow_tool_workspace_follows_persisted_run_owner(
+ sqlite_session: Session, caller_app: str, root_marker: str | None, persist_run: bool
+) -> None:
+ run = WorkflowRun(
+ id="run-1",
+ tenant_id="tenant-1",
+ app_id="app-1",
+ workflow_id="workflow-1",
+ type=WorkflowType.WORKFLOW,
+ triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
+ version="1",
+ graph="{}",
+ inputs="{}",
+ status=WorkflowExecutionStatus.SUCCEEDED,
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by="user-1",
+ )
+ workspace = _workspace_row(app_id="source-app")
+ binding = _binding_row()
+ binding.app_id = "source-app"
+ execution = _execution_row(
+ binding_id=binding.id,
+ process_data={WORKFLOW_TOOL_ROOT_APP_ID_KEY: root_marker} if root_marker is not None else None,
+ )
+ execution.app_id = "source-app"
+ execution.workflow_id = "source-workflow"
+ execution.triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ unlinked_workspace = _workspace_row(
+ workspace_id="unlinked-workspace", app_id="source-app", owner_scope_key="unlinked"
+ )
+ sqlite_session.add_all([workspace, binding, execution, unlinked_workspace])
+ if persist_run:
+ sqlite_session.add(run)
+ sqlite_session.commit()
+
+ result = WorkflowAgentWorkspaceStore().retire_workflow_run(
+ tenant_id="tenant-1", app_id=caller_app, workflow_run_id=run.id
+ )
+
+ sqlite_session.expire_all()
+ assert unlinked_workspace.status is AgentWorkingResourceStatus.ACTIVE
+ if caller_app == "app-1" and (root_marker == "app-1" or (root_marker is None and persist_run)):
+ assert result == [workspace.id]
+ assert workspace.status is AgentWorkingResourceStatus.RETIRED
+ assert binding.status is AgentWorkingResourceStatus.RETIRED
+ else:
+ assert result == []
+ assert workspace.status is AgentWorkingResourceStatus.ACTIVE
+ assert binding.status is AgentWorkingResourceStatus.ACTIVE
+
+
+@pytest.mark.parametrize(
+ "mismatch", ["caller-tenant", "caller-run", "caller-app", "binding-tenant", "binding-app", "origin"]
+)
+def test_retire_workflow_tool_workspace_checks_complete_caller_chain(sqlite_session: Session, mismatch: str) -> None:
+ workspace = _workspace_row(app_id="source-app")
+ binding = _binding_row()
+ binding.app_id = "source-app"
+ execution = _execution_row(binding_id=binding.id, process_data={WORKFLOW_TOOL_ROOT_APP_ID_KEY: "app-1"})
+ execution.app_id = "source-app"
+ execution.triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ if mismatch == "caller-tenant":
+ execution.tenant_id = "other-tenant"
+ elif mismatch == "caller-run":
+ execution.workflow_run_id = "other-run"
+ elif mismatch == "caller-app":
+ execution.app_id = "other-app"
+ elif mismatch == "binding-tenant":
+ binding.tenant_id = "other-tenant"
+ elif mismatch == "binding-app":
+ binding.app_id = "other-app"
+ else:
+ execution.triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN
+ sqlite_session.add_all([workspace, binding, execution])
+ sqlite_session.commit()
+
+ assert (
+ WorkflowAgentWorkspaceStore().retire_workflow_run(tenant_id="tenant-1", app_id="app-1", workflow_run_id="run-1")
+ == []
+ )
+ sqlite_session.expire_all()
+ assert workspace.status == AgentWorkingResourceStatus.ACTIVE
+ assert binding.status == AgentWorkingResourceStatus.ACTIVE
+
+
+@pytest.mark.parametrize("captured_owner", [False, True])
+def test_deleting_paused_root_app_retires_and_deletes_nested_participants(
+ sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], captured_owner: bool
+) -> None:
+ app = App(
+ id="app-1",
+ tenant_id="tenant-1",
+ name="Root",
+ mode=AppMode.WORKFLOW,
+ icon_type=IconType.EMOJI,
+ icon="chat",
+ icon_background="#fff",
+ enable_site=False,
+ enable_api=False,
+ )
+ workspace = _workspace_row(app_id="source-app")
+ binding = _binding_row()
+ binding.app_id = "source-app"
+ execution = _execution_row(
+ binding_id=binding.id,
+ process_data={WORKFLOW_TOOL_ROOT_APP_ID_KEY: "app-1"} if captured_owner else None,
+ )
+ execution.app_id = "source-app"
+ execution.triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ execution.status = WorkflowNodeExecutionStatus.PAUSED
+ unlinked = _workspace_row(workspace_id="unlinked", app_id="source-app", owner_scope_key="unlinked")
+ sqlite_session.add_all([app, workspace, binding, execution, unlinked])
+ if not captured_owner:
+ sqlite_session.add(
+ WorkflowRun(
+ id="run-1",
+ tenant_id="tenant-1",
+ app_id="app-1",
+ workflow_id="workflow-1",
+ type=WorkflowType.WORKFLOW,
+ triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
+ version="1",
+ graph="{}",
+ inputs="{}",
+ status=WorkflowExecutionStatus.PAUSED,
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by="user-1",
+ )
+ )
+ sqlite_session.commit()
+
+ with (
+ patch("services.app_service.current_user", None),
+ patch("services.app_service.app_was_deleted.send"),
+ patch("services.app_service.remove_app_and_related_data_task.delay"),
+ patch("services.app_service.WorkflowAgentRetirementService.retire_unowned"),
+ patch("services.app_service.SystemFeatureService.is_webapp_auth_enabled", return_value=False),
+ patch("services.app_service.enqueue_agent_resource_collection") as collect,
+ ):
+ AppService().delete_app(app, session=sqlite_session)
+
+ sqlite_session.expire_all()
+ assert workspace.status == AgentWorkingResourceStatus.RETIRED
+ assert binding.status == AgentWorkingResourceStatus.RETIRED
+ assert unlinked.status == AgentWorkingResourceStatus.ACTIVE
+ assert collect.call_args.kwargs["workspace_ids"] == [workspace.id]
+ assert (
+ DifyAPISQLAlchemyWorkflowNodeExecutionRepository(sqlite_session_factory).delete_executions_by_app(
+ "tenant-1", "app-1"
+ )
+ == 1
+ )
+ DifyAPISQLAlchemyWorkflowRunRepository(sqlite_session_factory).delete_runs_by_app("tenant-1", "app-1")
+ sqlite_session.expire_all()
+ assert sqlite_session.get(WorkflowNodeExecutionModel, "execution-1") is None
diff --git a/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py b/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py
index 55aa24ce44db26..934bfbe589234f 100644
--- a/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py
+++ b/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py
@@ -4,12 +4,12 @@
from core.repositories.human_input_repository import HumanInputFormRecord
from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons
-from core.workflow.nodes.human_input.boundary import build_human_input_pause_reason
+from core.workflow.nodes.human_input.boundary import build_human_input_pause_reason, human_input_container_selector
from core.workflow.nodes.human_input.entities import FormDefinition, SelectInputConfig, StringListSource
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType
from graphon.entities.pause_reason import HitlRequired
-from graphon.runtime import VariablePool
+from graphon.runtime import RuntimeState, VariablePool
from models.human_input import HumanInputForm
_HUMAN_INPUT_REASON = {"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, "form_id": "f1"}
@@ -72,7 +72,7 @@ def test_pause_reason_payload_carries_approval_channels_through_factory():
assert payload.form_token is None
-def test_build_pause_reason_keeps_constant_options_from_materialized_form():
+def test_build_pause_reason_keeps_constant_options_from_child_form():
definition = FormDefinition(
form_content="Choose",
rendered_content="Choose",
@@ -113,3 +113,50 @@ def test_build_pause_reason_keeps_constant_options_from_materialized_form():
assert isinstance(reason.inputs[0], SelectInputConfig)
assert reason.inputs[0].option_source.type == ValueSourceType.CONSTANT
assert reason.inputs[0].option_source.value == []
+
+
+def test_child_forms_keep_distinct_identity_when_projected_to_the_same_visible_tool():
+ definition = FormDefinition(
+ form_content="Approval",
+ rendered_content="Approval",
+ node_title="Approval",
+ expiration_time=datetime(2026, 1, 2),
+ )
+ state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0)
+ reasons = [
+ HitlRequired(session_id=form_id, node_id="source-human", node_title="Approval")
+ for form_id in ("form-a", "form-b")
+ ]
+ for reason in reasons:
+ state.variable_pool.add(human_input_container_selector(reason.session_id), "visible-tool")
+ restored = RuntimeState.from_snapshot(state.dumps())
+
+ enriched = [
+ build_human_input_pause_reason(
+ reason=reason,
+ record=HumanInputFormRecord.from_models(
+ HumanInputForm(
+ id=reason.session_id,
+ tenant_id="tenant",
+ app_id="app",
+ workflow_run_id="run",
+ node_id="source-human",
+ form_kind=HumanInputFormKind.RUNTIME,
+ form_definition=definition.model_dump_json(),
+ rendered_content=reason.session_id,
+ created_at=datetime(2026, 1, 1),
+ expiration_time=definition.expiration_time,
+ status=HumanInputFormStatus.WAITING,
+ ),
+ None,
+ ),
+ variable_pool=restored.variable_pool,
+ )
+ for reason in reasons
+ ]
+
+ assert [(reason.form_id, reason.node_id) for reason in enriched] == [
+ ("form-a", "visible-tool"),
+ ("form-b", "visible-tool"),
+ ]
+ assert all(reason.node_id == "source-human" for reason in reasons)
diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py
index 990f634f82e325..aec0ad64db6def 100644
--- a/api/tests/unit_tests/core/workflow/test_node_factory.py
+++ b/api/tests/unit_tests/core/workflow/test_node_factory.py
@@ -414,6 +414,7 @@ def factory(self):
app_type=None,
created_by=None,
)
+ factory._use_workflow_tool_containers = True
factory._code_executor = sentinel.code_executor
factory._code_limits = sentinel.code_limits
factory._jinja2_template_renderer = sentinel.jinja2_template_renderer
diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py
index c6b12df6346c11..c2ad7c936de992 100644
--- a/api/tests/unit_tests/core/workflow/test_node_runtime.py
+++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py
@@ -1,5 +1,6 @@
-from collections.abc import Iterator
+from collections.abc import Iterator, Mapping
from datetime import UTC, datetime
+from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, sentinel
from uuid import uuid4
@@ -48,6 +49,8 @@
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol
from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType
+from graphon.nodes.tool.exc import ToolRuntimeResolutionError
+from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle
from graphon.variables.segments import ArrayFileSegment, FileSegment
from models.base import TypeBase
from models.dataset import SegmentAttachmentBinding
@@ -889,6 +892,71 @@ def test_dify_tool_node_runtime_does_not_inject_outer_workflow_run_id_for_non_wo
get_runtime.assert_called_once()
+def test_dify_tool_node_runtime_builds_workflow_tool_container_payload(monkeypatch: pytest.MonkeyPatch) -> None:
+ from core.tools.__base.tool_runtime import ToolRuntime
+ from core.tools.entities.common_entities import I18nObject
+ from core.tools.entities.tool_entities import ToolEntity, ToolIdentity
+ from core.tools.workflow_as_tool.tool import WorkflowTool
+
+ tool = WorkflowTool(
+ workflow_app_id=str(uuid4()),
+ workflow_id=str(uuid4()),
+ workflow_as_tool_id="workflow-tool-id",
+ version="published-version",
+ workflow_call_depth=2,
+ entity=ToolEntity(
+ identity=ToolIdentity(
+ author="test",
+ name="workflow-tool",
+ label=I18nObject(en_US="Workflow Tool"),
+ provider="workflow-provider",
+ ),
+ parameters=[],
+ description=None,
+ has_runtime_parameters=False,
+ ),
+ runtime=ToolRuntime(tenant_id="tenant-id", invoke_from=InvokeFrom.DEBUGGER),
+ )
+ prepared_parameters: list[Mapping[str, object]] = []
+
+ def prepare_container_inputs(parameters: Mapping[str, object]) -> tuple[dict[str, object], list[dict[str, str]]]:
+ prepared_parameters.append(parameters)
+ return {"amount": Decimal("1.25")}, [{"id": "file-id", "name": "input.txt"}]
+
+ monkeypatch.setattr(tool, "prepare_container_inputs", prepare_container_inputs)
+ runtime = DifyToolNodeRuntime(_build_run_context())
+
+ payload = runtime.build_workflow_tool_container_payload(
+ tool_runtime=ToolRuntimeHandle(raw=tool),
+ tool_parameters={"amount": "1.25"},
+ inputs_for_log={"amount": Decimal("1.25")},
+ workflow_call_depth=2,
+ )
+
+ assert payload.model_dump() == {
+ "version": "1",
+ "source_app_id": str(tool.workflow_app_id),
+ "source_workflow_id": str(tool.workflow_id),
+ "source_workflow_version": "published-version",
+ "inputs": {"amount": 1.25},
+ "system_files": [{"id": "file-id", "name": "input.txt"}],
+ "inputs_for_log": {"amount": 1.25},
+ "call_depth": 3,
+ }
+ assert prepared_parameters == [{"amount": "1.25"}]
+
+
+def test_dify_tool_node_runtime_rejects_non_workflow_tool_container_payload() -> None:
+ runtime = DifyToolNodeRuntime(_build_run_context())
+ with pytest.raises(ToolRuntimeResolutionError, match="resolved tool is not a Workflow Tool"):
+ runtime.build_workflow_tool_container_payload(
+ tool_runtime=ToolRuntimeHandle(raw=object()),
+ tool_parameters={},
+ inputs_for_log={},
+ workflow_call_depth=0,
+ )
+
+
def test_dify_human_input_runtime_builds_debug_repository(monkeypatch: pytest.MonkeyPatch) -> None:
repository = MagicMock()
repository_cls = MagicMock(return_value=repository)
diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py
index 36e60ce7a7fefe..0ae05f7ddcfe83 100644
--- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py
+++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py
@@ -12,7 +12,7 @@
from core.workflow import workflow_entry
from core.workflow.system_variables import default_system_variables
from graphon.engine.filter import ResponseStreamFilter
-from graphon.engine_events import GraphRunFailedEvent, NodeRunSucceededEvent
+from graphon.engine_events import GraphRunAbortedEvent, GraphRunFailedEvent, NodeRunSucceededEvent
from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import NodeType, WorkflowNodeExecutionStatus
from graphon.errors import WorkflowNodeRunFailedError
@@ -23,6 +23,12 @@
from graphon.runtime import VariablePool
from graphon.variables.variables import StringVariable
from models.workflow import Workflow
+from tests.unit_tests.config_override import config_overrides_context
+from tests.unit_tests.core.workflow.test_workflow_tool_container import (
+ _outer_graph,
+ _workflow_tool_node,
+ _workflow_tool_source,
+)
from tests.unit_tests.model_factories import make_workflow
@@ -69,6 +75,23 @@ def test_rejects_call_depth_above_limit(self):
graph_runtime_state=sentinel.graph_runtime_state,
)
+ def test_hidden_workflow_tool_nodes_count_toward_execution_limit(self):
+ tool, _, _ = _workflow_tool_node()
+ source = _workflow_tool_source()
+ with config_overrides_context(WORKFLOW_MAX_EXECUTION_STEPS=2, GRAPH_ENGINE_MAX_WORKERS=1):
+ entry = workflow_entry.WorkflowEntry(
+ graph_config=tool.init_params.graph_config,
+ graph=_outer_graph(tool),
+ call_depth=0,
+ graph_runtime_state=tool.runtime_state,
+ )
+ entry.workflow_tool_sources[source.workflow_id] = source
+ events = list(entry.run())
+
+ assert isinstance(events[-1], GraphRunAbortedEvent)
+ assert "Maximum execution steps exceeded: 3 > 2" in events[-1].reason
+ assert not any(isinstance(event, NodeRunSucceededEvent) and event.node_id == "tool" for event in events)
+
def test_workflow_entry_stores_supplied_response_stream_filter(self, monkeypatch: pytest.MonkeyPatch) -> None:
supplied_filter = ResponseStreamFilter()
entry = _build_minimal_workflow_entry(monkeypatch, response_stream_filter=supplied_filter)
diff --git a/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py b/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py
new file mode 100644
index 00000000000000..b7ed47ce861719
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py
@@ -0,0 +1,1077 @@
+from __future__ import annotations
+
+import json
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import UTC, datetime
+from functools import partial
+
+import pytest
+from pytest_mock import MockerFixture
+
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.helper.code_executor.code_executor import CodeExecutionError
+from core.repositories.human_input_repository import (
+ HumanInputFormRepositoryImpl,
+ HumanInputFormSubmissionRepository,
+)
+from core.tools.__base.tool_runtime import ToolRuntime
+from core.tools.entities.common_entities import I18nObject
+from core.tools.entities.tool_entities import ToolEntity, ToolIdentity
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
+from core.tools.workflow_as_tool.tool import WorkflowTool
+from core.workflow.node_factory import DifyNodeFactory
+from core.workflow.node_runtime import DifyToolFileManager, DifyToolNodeRuntime
+from core.workflow.nodes.human_input.boundary import resolve_human_input_node_id
+from core.workflow.nodes.human_input.callback import DifyHITLCallback
+from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
+from core.workflow.system_variables import SystemVariableKey, system_variable_selector
+from core.workflow.workflow_tool_container_handler import (
+ WorkflowToolContainerHandler,
+ WorkflowToolNestedContainerHandler,
+)
+from core.workflow.workflow_tool_container_types import WorkflowToolContainerPayload
+from core.workflow.workflow_tool_node import DifyWorkflowToolNode
+from enums import WorkflowKind
+from graphon.engine import Engine
+from graphon.engine.command import AbortCommand, InMemoryChannel
+from graphon.engine.container_handler.builtin.loop import LoopContainerHandler
+from graphon.engine.event.node_failure import NodeFailureHandler
+from graphon.engine.event.processor import NodeEventProcessor
+from graphon.engine.event.stream import EventStream
+from graphon.engine.frame import ExecutionFrame, FrameRegistry
+from graphon.engine.ready_queue import ResumeTask, StartTask
+from graphon.engine.scheduler import Scheduler
+from graphon.engine.worker import NodeEventTask
+from graphon.engine_events import NodeRunExceptionEvent, NodeRunFailedEvent, NodeRunRetryEvent, NodeRunSucceededEvent
+from graphon.engine_events.base import EngineEvent, NodeEvent
+from graphon.engine_events.graph import (
+ GraphRunAbortedEvent,
+ GraphRunFailedEvent,
+ GraphRunPartialSucceededEvent,
+ GraphRunPausedEvent,
+ GraphRunSucceededEvent,
+)
+from graphon.engine_events.node import NodeRunPauseRequestedEvent
+from graphon.entities.base_node_data import DefaultValue, DefaultValueType
+from graphon.entities.pause_reason import HitlRequired
+from graphon.enums import (
+ BuiltinNodeTypes,
+ ErrorStrategy,
+ WorkflowNodeExecutionMetadataKey,
+ WorkflowNodeExecutionStatus,
+)
+from graphon.graph import Graph
+from graphon.node_events import StreamChunkEvent, StreamCompletedEvent
+from graphon.nodes.container_effects import (
+ ContainerExecutionResult,
+ ContainerNodeRunResult,
+ CustomContainerRequest,
+ LoopFrameRequest,
+ build_container_value,
+)
+from graphon.nodes.end.end_node import EndNode
+from graphon.nodes.end.entities import EndNodeData
+from graphon.nodes.start.entities import StartNodeData
+from graphon.nodes.start.start_node import StartNode
+from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType
+from graphon.nodes.tool.exc import ToolNodeError
+from graphon.nodes.tool.tool_node import ToolNode
+from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle
+from graphon.runtime import RuntimeState, VariablePool
+from graphon.runtime.container_state import create_container_run_state
+from graphon.runtime.execution import ROOT_FRAME_ID
+from graphon.variables.segments import ArrayFileSegment
+from graphon.variables.types import SegmentType
+from tests.workflow_test_utils import build_test_graph_init_params, build_test_run_context
+
+
+def _workflow_tool_node(
+ runtime_state: RuntimeState | None = None,
+ *,
+ app_id: str = "outer-app",
+ version: str = "1",
+ tool_node_version: str | None = None,
+) -> tuple[DifyWorkflowToolNode, DifyToolNodeRuntime, WorkflowToolContainerPayload]:
+ graph_config = {
+ "nodes": [
+ {
+ "id": "tool",
+ "data": {
+ "type": "tool",
+ "title": "Workflow Tool",
+ "provider_id": "workflow-provider",
+ "provider_type": "workflow",
+ "provider_name": "workflow-provider",
+ "tool_name": "nested-workflow",
+ "tool_label": "Nested Workflow",
+ "tool_configurations": dict[str, object](),
+ "tool_parameters": dict[str, object](),
+ "version": version,
+ "tool_node_version": tool_node_version,
+ },
+ }
+ ],
+ "edges": list[dict[str, object]](),
+ }
+ init_params = build_test_graph_init_params(
+ workflow_id="outer-workflow",
+ graph_config=graph_config,
+ call_depth=2,
+ app_id=app_id,
+ )
+ if runtime_state is None:
+ runtime_state = RuntimeState(
+ workflow_id="outer-workflow",
+ variable_pool=VariablePool(),
+ start_at=1,
+ )
+ payload = WorkflowToolContainerPayload(
+ source_app_id="source-app",
+ source_workflow_id="source-workflow",
+ source_workflow_version="1",
+ inputs={"question": "hello"},
+ inputs_for_log={},
+ call_depth=3,
+ )
+ tool = WorkflowTool(
+ workflow_app_id=payload.source_app_id,
+ workflow_id=payload.source_workflow_id,
+ workflow_as_tool_id="workflow-provider",
+ version=payload.source_workflow_version,
+ workflow_call_depth=2,
+ entity=ToolEntity(
+ identity=ToolIdentity(
+ author="test",
+ name="nested-workflow",
+ label=I18nObject(en_US="Nested Workflow"),
+ provider="workflow-provider",
+ ),
+ parameters=[],
+ description=None,
+ has_runtime_parameters=False,
+ ),
+ runtime=ToolRuntime(tenant_id="tenant", invoke_from=InvokeFrom.DEBUGGER),
+ )
+ tool.prepare_container_inputs = lambda *_args, **_kwargs: (payload.inputs, [])
+ runtime = DifyToolNodeRuntime(init_params.run_context)
+ runtime.get_runtime = lambda **_kwargs: ToolRuntimeHandle(raw=tool)
+ node = DifyWorkflowToolNode(
+ node_id="tool",
+ data=ToolNodeData.model_validate(graph_config["nodes"][0]["data"]),
+ init_params=init_params,
+ runtime_state=runtime_state,
+ tool_file_manager=DifyToolFileManager(init_params.run_context),
+ runtime=runtime,
+ )
+ node.bind_execution_id("tool-execution")
+ return node, runtime, payload
+
+
+def _outer_graph(node: DifyWorkflowToolNode) -> Graph:
+ start = StartNode(
+ node_id="outer-start",
+ data=StartNodeData(title="Start", variables=[]),
+ init_params=node.init_params,
+ runtime_state=node.runtime_state,
+ )
+ return Graph.new().add_root(start).add_node(node, from_node_id=start.id).build()
+
+
+def test_workflow_tool_node_requests_child_container_and_resumes_successfully(mocker: MockerFixture) -> None:
+ node, runtime, payload = _workflow_tool_node()
+ get_runtime = mocker.spy(runtime, "get_runtime")
+ build_payload = mocker.spy(runtime, "build_workflow_tool_container_payload")
+
+ initial_events = list(node._run())
+
+ assert initial_events == [CustomContainerRequest(payload=payload.model_dump_json())]
+ get_runtime.assert_called_once_with(
+ node_id="tool",
+ node_data=node.node_data,
+ variable_pool=None,
+ node_execution_id="tool-execution",
+ )
+ build_payload.assert_called_once_with(
+ tool_runtime=get_runtime.spy_return,
+ tool_parameters={},
+ inputs_for_log={},
+ workflow_call_depth=2,
+ )
+
+ result = ContainerExecutionResult(
+ metadata={},
+ steps=2,
+ node_run_result=ContainerNodeRunResult(
+ status=WorkflowNodeExecutionStatus.SUCCEEDED,
+ inputs={"question": build_container_value("hello")},
+ outputs={
+ "text": build_container_value("done"),
+ "answer": build_container_value(42),
+ "files": WorkflowToolContainerHandler._build_tool_outputs({})["files"],
+ },
+ ),
+ )
+ assert result.node_run_result.outputs["files"].value_type == SegmentType.ARRAY_FILE
+ node.runtime_state.enqueue_ready_task(ResumeTask(invocation_id="invocation", result=result))
+ restored_state = RuntimeState.from_snapshot(node.runtime_state.dumps())
+ resume_task = restored_state.ready_queue.get(timeout=0.01)
+ assert isinstance(resume_task, ResumeTask)
+ assert isinstance(resume_task.result, ContainerExecutionResult)
+ assert resume_task.result.node_run_result.outputs["files"].value_type == SegmentType.ARRAY_FILE
+ restored_node, _, _ = _workflow_tool_node(restored_state)
+
+ resumed_events = list(restored_node._resume_container_events(result=resume_task.result))
+
+ assert resumed_events[:2] == [
+ StreamChunkEvent(selector=["tool", "text"], chunk="done", is_final=False),
+ StreamChunkEvent(selector=["tool", "text"], chunk="", is_final=True),
+ ]
+ completed = resumed_events[2]
+ assert isinstance(completed, StreamCompletedEvent)
+ assert completed.node_run_result.inputs == {"question": "hello"}
+ assert {key: value.to_object() for key, value in completed.node_run_result.outputs.items()} == {
+ "text": "done",
+ "answer": 42,
+ "files": [],
+ }
+ assert isinstance(completed.node_run_result.outputs["files"], ArrayFileSegment)
+ for key, value in completed.node_run_result.outputs.items():
+ restored_state.variable_pool.add(("tool", key), value)
+ files = restored_state.variable_pool.get(("tool", "files"))
+ assert files is not None
+ assert files.value_type == SegmentType.ARRAY_FILE
+ assert files.to_object() == []
+ assert node.runtime_state.node_run_steps == 0
+ assert completed.node_run_result.metadata[WorkflowNodeExecutionMetadataKey.TOOL_INFO] == {
+ "provider_type": ToolProviderType.WORKFLOW.value,
+ "provider_id": "workflow-provider",
+ "plugin_unique_identifier": None,
+ }
+
+
+def test_workflow_tool_node_returns_failure_when_runtime_cannot_be_loaded(mocker: MockerFixture) -> None:
+ node, runtime, _ = _workflow_tool_node(version="2")
+ get_runtime = mocker.patch.object(runtime, "get_runtime", side_effect=ToolNodeError("runtime unavailable"))
+
+ events = list(node._run())
+
+ assert len(events) == 1
+ completed = events[0]
+ assert isinstance(completed, StreamCompletedEvent)
+ assert completed.node_run_result.status == WorkflowNodeExecutionStatus.FAILED
+ assert completed.node_run_result.error == "Failed to get tool runtime: runtime unavailable"
+ get_runtime.assert_called_once_with(
+ node_id="tool",
+ node_data=node.node_data,
+ variable_pool=node.runtime_state.variable_pool,
+ node_execution_id="tool-execution",
+ )
+
+
+def test_workflow_tool_node_returns_failure_when_payload_cannot_be_built(mocker: MockerFixture) -> None:
+ node, runtime, _ = _workflow_tool_node(tool_node_version="1")
+ mocker.patch.object(
+ runtime, "build_workflow_tool_container_payload", side_effect=ToolNodeError("invalid source workflow")
+ )
+
+ events = list(node._run())
+
+ assert len(events) == 1
+ completed = events[0]
+ assert isinstance(completed, StreamCompletedEvent)
+ assert completed.node_run_result.status == WorkflowNodeExecutionStatus.FAILED
+ assert completed.node_run_result.error == "Failed to prepare Workflow Tool: invalid source workflow"
+
+
+def test_workflow_tool_node_rejects_non_execution_result() -> None:
+ node, _, _ = _workflow_tool_node()
+ result = LoopFrameRequest(
+ inputs={},
+ outputs={},
+ loop_count=0,
+ root_node_id="loop",
+ loop_variable_selectors={},
+ loop_node_ids=frozenset(),
+ index=0,
+ )
+
+ with pytest.raises(TypeError, match="Unsupported Workflow Tool container result LoopFrameRequest"):
+ list(node._resume_container_events(result=result))
+
+
+def test_workflow_tool_node_resumes_failed_result_without_chunks() -> None:
+ node, _, _ = _workflow_tool_node()
+ result = ContainerExecutionResult(
+ metadata={},
+ steps=1,
+ node_run_result=ContainerNodeRunResult(
+ status=WorkflowNodeExecutionStatus.FAILED,
+ error="child failed",
+ ),
+ )
+
+ events = list(node._resume_container_events(result=result))
+
+ assert len(events) == 1
+ assert isinstance(events[0], StreamCompletedEvent)
+ assert events[0].node_run_result.error == "child failed"
+
+
+def test_workflow_tool_node_resumes_empty_text_with_final_chunk_only() -> None:
+ node, _, _ = _workflow_tool_node()
+ result = ContainerExecutionResult(
+ metadata={},
+ steps=0,
+ node_run_result=ContainerNodeRunResult(
+ status=WorkflowNodeExecutionStatus.SUCCEEDED,
+ outputs={"text": build_container_value("")},
+ ),
+ )
+
+ events = list(node._resume_container_events(result=result))
+
+ assert events[:1] == [StreamChunkEvent(selector=["tool", "text"], chunk="", is_final=True)]
+ assert isinstance(events[1], StreamCompletedEvent)
+
+
+def test_node_factory_can_keep_workflow_tool_direct_for_single_step_debug() -> None:
+ node, _, _ = _workflow_tool_node()
+ factory = DifyNodeFactory(node.init_params, node.runtime_state, use_workflow_tool_containers=True)
+ assert (
+ factory._resolve_node_class_for_factory(
+ node_type=BuiltinNodeTypes.TOOL,
+ node_version="1",
+ node_data=node.node_data,
+ )
+ is DifyWorkflowToolNode
+ )
+
+ factory._use_workflow_tool_containers = False
+ assert (
+ factory._resolve_node_class_for_factory(
+ node_type=BuiltinNodeTypes.TOOL,
+ node_version="1",
+ node_data=node.node_data,
+ )
+ is ToolNode
+ )
+
+
+def _workflow_tool_source() -> WorkflowToolSource:
+ source_graph = {
+ "nodes": [
+ {
+ "id": "source-start",
+ "data": {
+ "type": "start",
+ "title": "Start",
+ "variables": [
+ {
+ "variable": "answer",
+ "label": "Answer",
+ "type": "text-input",
+ "required": False,
+ "default": "fallback",
+ }
+ ],
+ },
+ },
+ {
+ "id": "source-end",
+ "data": {
+ "type": "end",
+ "title": "End",
+ "outputs": [
+ {
+ "variable": "answer",
+ "value_selector": ["source-start", "answer"],
+ }
+ ],
+ },
+ },
+ ],
+ "edges": [
+ {
+ "id": "source-edge",
+ "source": "source-start",
+ "target": "source-end",
+ }
+ ],
+ }
+ return WorkflowToolSource(
+ app_id="source-app",
+ workflow_id="source-workflow",
+ graph_config=source_graph,
+ features_dict={},
+ environment_variables=[],
+ workflow_kind=WorkflowKind.STANDARD,
+ )
+
+
+def _human_input_workflow_tool_source() -> WorkflowToolSource:
+ human_input = HumanInputNodeData(
+ title="Approval",
+ form_content="Approve this run?",
+ user_actions=[UserActionConfig(id="approve", title="Approve")],
+ )
+ source_graph = {
+ "nodes": [
+ {
+ "id": "source-start",
+ "data": {"type": "start", "title": "Start", "variables": list[object]()},
+ },
+ {
+ "id": "source-human",
+ "data": human_input.model_dump(mode="json"),
+ },
+ {
+ "id": "source-end",
+ "data": {
+ "type": "end",
+ "title": "End",
+ "outputs": [
+ {
+ "variable": "decision",
+ "value_selector": ["source-human", "__action_id"],
+ }
+ ],
+ },
+ },
+ ],
+ "edges": [
+ {
+ "id": "source-edge-start-human",
+ "source": "source-start",
+ "target": "source-human",
+ },
+ {
+ "id": "source-edge-human-end",
+ "source": "source-human",
+ "sourceHandle": "approve",
+ "target": "source-end",
+ },
+ ],
+ }
+ return replace(_workflow_tool_source(), graph_config=source_graph)
+
+
+def _container_handler(
+ *,
+ inputs: dict[str, object] | None = None,
+) -> tuple[
+ WorkflowToolContainerHandler,
+ FrameRegistry,
+ RuntimeState,
+ CustomContainerRequest,
+ dict[str, WorkflowToolSource],
+]:
+ source = _workflow_tool_source()
+ parent_pool = VariablePool()
+ parent_pool.add(system_variable_selector(SystemVariableKey.USER_ID), "user")
+ parent_pool.add(system_variable_selector(SystemVariableKey.WORKFLOW_EXECUTION_ID), "outer-execution")
+ parent_pool.add(("env", "outer-secret"), "must-not-leak")
+ parent_pool.add(("source-start", "stale"), "must-not-leak")
+ parent_state = RuntimeState(
+ workflow_id="outer-workflow",
+ variable_pool=parent_pool,
+ start_at=1,
+ )
+ parent_node, _, _ = _workflow_tool_node(parent_state)
+ parent_graph = _outer_graph(parent_node)
+ frame_registry = FrameRegistry()
+ frame_registry.register(
+ ExecutionFrame(
+ frame_id=ROOT_FRAME_ID,
+ graph=parent_graph,
+ state=parent_state,
+ scheduler=Scheduler(parent_graph, parent_state, ROOT_FRAME_ID),
+ failure_handler=NodeFailureHandler(parent_graph, parent_state.graph_execution),
+ )
+ )
+ source_repository = {}
+ source_repository["source-workflow"] = source
+ payload = WorkflowToolContainerPayload(
+ source_app_id=source.app_id,
+ source_workflow_id=source.workflow_id,
+ source_workflow_version="1",
+ inputs={"answer": "ok"} if inputs is None else inputs,
+ inputs_for_log={"answer": "ok"} if inputs is None else inputs,
+ call_depth=1,
+ )
+ request = CustomContainerRequest(payload=payload.model_dump_json())
+ parent_state.put_container_run(
+ create_container_run_state(
+ invocation_id="invocation",
+ frame_id=ROOT_FRAME_ID,
+ node_id="tool",
+ started_at=datetime.now(UTC).replace(tzinfo=None),
+ request=request,
+ )
+ )
+ return (
+ WorkflowToolContainerHandler(frame_registry, sources=source_repository),
+ frame_registry,
+ parent_state,
+ request,
+ source_repository,
+ )
+
+
+def _dispatch_next_node(
+ *,
+ expected_node_id: str,
+ runtime_state: RuntimeState,
+ frame_registry: FrameRegistry,
+ processor: NodeEventProcessor,
+) -> None:
+ task = runtime_state.ready_queue.get(timeout=0.01)
+ assert isinstance(task, StartTask)
+ assert task.node_id == expected_node_id
+ frame = frame_registry[task.frame_id]
+ node = frame.graph.nodes[task.node_id]
+ execution = runtime_state.graph_execution.get_or_create_node_execution(
+ frame_id=frame.frame_id,
+ node_id=node.id,
+ )
+ node.bind_execution_id(execution.execution_id)
+ for event in node.run():
+ assert isinstance(event, NodeEvent)
+ processor.dispatch(NodeEventTask(frame_id=frame.frame_id, event=event))
+
+
+def test_workflow_tool_handler_runs_child_graph_with_internal_name_collision() -> None:
+ handler, frame_registry, runtime_state, request, _ = _container_handler()
+ handler.handle_request(invocation_id="invocation", request=request)
+ frame_registry["invocation:workflow-tool"].state.variable_pool.add(
+ ("__workflow_tool_container__", "failure"),
+ {"legitimate": True},
+ )
+ event_stream = EventStream(layers=[])
+ processor = NodeEventProcessor(
+ graph_execution=runtime_state.graph_execution,
+ event_stream=event_stream,
+ frame_registry=frame_registry,
+ container_handlers={BuiltinNodeTypes.TOOL: handler},
+ )
+
+ _dispatch_next_node(
+ expected_node_id="source-start",
+ runtime_state=runtime_state,
+ frame_registry=frame_registry,
+ processor=processor,
+ )
+ _dispatch_next_node(
+ expected_node_id="source-end",
+ runtime_state=runtime_state,
+ frame_registry=frame_registry,
+ processor=processor,
+ )
+
+ resume_task = runtime_state.ready_queue.get(timeout=0.01)
+ assert isinstance(resume_task, ResumeTask)
+ assert isinstance(resume_task.result, ContainerExecutionResult)
+ assert resume_task.result.steps == 2
+ assert resume_task.result.node_run_result.outputs["files"].value_type == SegmentType.ARRAY_FILE
+ outputs = {key: value.to_object() for key, value in resume_task.result.node_run_result.outputs.items()}
+ assert outputs == {
+ "answer": "ok",
+ "text": '{"answer": "ok"}',
+ "files": [],
+ "json": [{"answer": "ok"}],
+ }
+ event_stream.mark_complete()
+ assert not any(isinstance(event, NodeEvent) for event in event_stream.emit_events())
+ with pytest.raises(KeyError):
+ frame_registry["invocation:workflow-tool"]
+ with pytest.raises(KeyError):
+ runtime_state.get_container_frame("invocation:workflow-tool")
+
+
+def test_workflow_tool_handler_restores_child_failure() -> None:
+ handler, frame_registry, runtime_state, request, source_repository = _container_handler()
+ handler.handle_request(invocation_id="invocation", request=request)
+ frame_id = "invocation:workflow-tool"
+ frame = frame_registry[frame_id]
+ now = datetime.now(UTC).replace(tzinfo=None)
+ handler.record_frame_failure(
+ frame=frame,
+ event=NodeRunFailedEvent(
+ id="failed-execution",
+ node_id="source-start",
+ node_type=BuiltinNodeTypes.START,
+ error="source failed",
+ start_at=now,
+ finished_at=now,
+ node_run_result=ContainerNodeRunResult(
+ status=WorkflowNodeExecutionStatus.FAILED,
+ error="source failed",
+ error_type="RuntimeError",
+ ).to_node_run_result(),
+ ),
+ )
+ frame_state = runtime_state.get_container_frame(frame_id)
+ frame_state = frame_state.model_copy(update={"runtime_data": frame.state.snapshot_frame()})
+ runtime_state.put_container_frame(frame_state)
+ frame_registry.remove(frame_id)
+
+ restored_handler = WorkflowToolContainerHandler(frame_registry, sources=source_repository)
+ restored_handler.restore_frame(frame_state)
+ restored_handler.complete_frame_if_ready(frame_registry[frame_id])
+
+ tasks = [runtime_state.ready_queue.get(timeout=0.01) for _ in range(2)]
+ resume_task = next(task for task in tasks if isinstance(task, ResumeTask))
+ assert isinstance(resume_task.result, ContainerExecutionResult)
+ assert resume_task.result.node_run_result.status == WorkflowNodeExecutionStatus.FAILED
+ assert {key: value.to_object() for key, value in resume_task.result.node_run_result.inputs.items()} == {
+ "answer": "ok"
+ }
+ assert resume_task.result.node_run_result.error == "source failed"
+ assert resume_task.result.node_run_result.error_type == "RuntimeError"
+
+
+def test_workflow_tool_handler_isolates_child_variable_pool() -> None:
+ handler, frame_registry, _, request, _ = _container_handler()
+
+ handler.handle_request(invocation_id="invocation", request=request)
+
+ child_pool = frame_registry["invocation:workflow-tool"].state.variable_pool
+ assert child_pool.get(system_variable_selector(SystemVariableKey.USER_ID)).to_object() == "user" # type: ignore[union-attr]
+ assert child_pool.get(system_variable_selector(SystemVariableKey.WORKFLOW_EXECUTION_ID)).to_object() == ( # type: ignore[union-attr]
+ "outer-execution"
+ )
+ assert child_pool.get(system_variable_selector(SystemVariableKey.APP_ID)).to_object() == ( # type: ignore[union-attr]
+ "source-app"
+ )
+ assert child_pool.get(("env", "outer-secret")) is None
+ assert child_pool.get(("source-start", "stale")) is None
+
+
+def test_workflow_tool_handler_applies_start_input_defaults() -> None:
+ handler, frame_registry, _, request, _ = _container_handler(inputs={})
+
+ handler.handle_request(invocation_id="invocation", request=request)
+
+ answer = frame_registry["invocation:workflow-tool"].state.variable_pool.get(("source-start", "answer"))
+ assert answer is not None
+ assert answer.to_object() == "fallback"
+
+
+def test_workflow_tool_handler_preserves_inputs_when_start_validation_fails() -> None:
+ handler, _, runtime_state, request, _ = _container_handler(inputs={"answer": 42})
+
+ handler.handle_request(invocation_id="invocation", request=request)
+
+ resume_task = runtime_state.ready_queue.get(timeout=0.01)
+ assert isinstance(resume_task, ResumeTask)
+ assert isinstance(resume_task.result, ContainerExecutionResult)
+ assert resume_task.result.node_run_result.status == WorkflowNodeExecutionStatus.FAILED
+ assert {key: value.to_object() for key, value in resume_task.result.node_run_result.inputs.items()} == {
+ "answer": 42
+ }
+ assert resume_task.result.node_run_result.error_type == "ValueError"
+
+
+def test_workflow_tool_nested_handler_hides_and_persists_marked_child_events() -> None:
+ _, frame_registry, _, request, repository = _container_handler()
+ persisted: list[NodeEvent] = []
+ event_listeners: dict[str, Callable[[NodeEvent], None]] = {}
+ workflow_tool_handler = WorkflowToolContainerHandler(
+ frame_registry,
+ sources=repository,
+ event_listener_factory=lambda *_: persisted.append,
+ event_listeners=event_listeners,
+ )
+ workflow_tool_handler.handle_request(invocation_id="invocation", request=request)
+ built_in_handler = LoopContainerHandler(frame_registry)
+ hidden_events: list[NodeEvent] = []
+ nested_handler = WorkflowToolNestedContainerHandler(
+ frame_registry,
+ handler_factory=lambda _: built_in_handler,
+ hidden_event_listener=hidden_events.append,
+ event_listeners=event_listeners,
+ )
+ event = NodeRunSucceededEvent(
+ id="source-execution",
+ node_id="source-start",
+ node_type=BuiltinNodeTypes.START,
+ start_at=datetime.now(UTC).replace(tzinfo=None),
+ container_id="nested-container",
+ )
+
+ workflow_tool_handler.prepare_frame_event(
+ frame=frame_registry["invocation:workflow-tool"],
+ event=event,
+ )
+
+ assert nested_handler.should_emit(event=event) is False
+ assert hidden_events == [event]
+ assert len(persisted) == 1
+ assert persisted[0].container_id == "nested-container"
+ assert persisted[0].node_run_result.process_data["workflow_tool_invocation_id"] == "invocation"
+ assert "__dify_workflow_tool_child__" not in persisted[0].node_run_result.process_data
+
+ # Another Workflow Tool inside this container owns its own source events.
+ inner_events: list[NodeEvent] = []
+ event_listeners["inner-tool-frame"] = inner_events.append
+ inner_event = event.model_copy(deep=True)
+ inner_event.node_run_result.process_data = {}
+ workflow_tool_handler.prepare_frame_event(
+ frame=replace(frame_registry["invocation:workflow-tool"], frame_id="inner-tool-frame"), event=inner_event
+ )
+ workflow_tool_handler.prepare_frame_event(frame=frame_registry["invocation:workflow-tool"], event=inner_event)
+ assert nested_handler.should_emit(event=inner_event) is False
+ assert inner_events == [inner_event]
+ assert len(persisted) == 1
+
+ unmarked_event = event.model_copy(deep=True)
+ unmarked_event.node_run_result.process_data = {}
+ assert nested_handler.should_emit(event=unmarked_event) is True
+ assert len(persisted) == 1
+
+
+@pytest.mark.parametrize(
+ "source_metadata",
+ [{}, {WorkflowNodeExecutionMetadataKey.LOOP_ID: "source-loop", WorkflowNodeExecutionMetadataKey.LOOP_INDEX: 1}],
+)
+def test_workflow_tool_persistence_keeps_source_container_metadata(
+ source_metadata: dict[WorkflowNodeExecutionMetadataKey, str | int],
+) -> None:
+ _, frames, _, request, repository = _container_handler()
+ persisted: list[NodeEvent] = []
+ handler = WorkflowToolContainerHandler(
+ frames, sources=repository, event_listener_factory=lambda *_: persisted.append
+ )
+ handler.handle_request(invocation_id="invocation", request=request)
+ event = NodeRunSucceededEvent(
+ id="source-execution",
+ node_id="source-start",
+ node_type=BuiltinNodeTypes.START,
+ start_at=datetime.now(UTC).replace(tzinfo=None),
+ )
+ event.node_run_result.metadata = source_metadata.copy()
+ handler.prepare_frame_event(frame=frames["invocation:workflow-tool"], event=event)
+ # Ancestors outside the Tool source add their own ownership before collection.
+ event.node_run_result.metadata.update(
+ {
+ WorkflowNodeExecutionMetadataKey.ITERATION_ID: "outer-iteration",
+ WorkflowNodeExecutionMetadataKey.ITERATION_INDEX: 2,
+ }
+ )
+ event.node_run_result.outputs = {"normalized": True}
+
+ assert handler.should_emit(event=event) is False
+
+ assert persisted[0].node_run_result.metadata == source_metadata
+ assert persisted[0].node_run_result.outputs == {"normalized": True}
+
+
+@pytest.mark.parametrize(
+ ("child_error_strategy", "tool_error_strategy", "terminal_type", "expected_count", "expected_output", "retry_once"),
+ [
+ pytest.param(
+ ErrorStrategy.DEFAULT_VALUE,
+ None,
+ GraphRunSucceededEvent,
+ 0,
+ '{"answer": "child fallback"}',
+ False,
+ id="child-handles-failure",
+ ),
+ pytest.param(None, None, GraphRunFailedEvent, 1, None, False, id="unhandled-failure"),
+ pytest.param(
+ None,
+ ErrorStrategy.DEFAULT_VALUE,
+ GraphRunPartialSucceededEvent,
+ 1,
+ "tool fallback",
+ False,
+ id="tool-default-value",
+ ),
+ pytest.param(
+ None,
+ ErrorStrategy.FAIL_BRANCH,
+ GraphRunPartialSucceededEvent,
+ 1,
+ "source code failed",
+ False,
+ id="tool-fail-branch",
+ ),
+ pytest.param(None, None, GraphRunSucceededEvent, 0, '{"answer": "retried output"}', True, id="child-retries"),
+ ],
+)
+def test_workflow_tool_failure_accounting_uses_outer_tool_policy(
+ monkeypatch: pytest.MonkeyPatch,
+ child_error_strategy: ErrorStrategy | None,
+ tool_error_strategy: ErrorStrategy | None,
+ terminal_type: type[GraphRunSucceededEvent | GraphRunFailedEvent | GraphRunPartialSucceededEvent],
+ expected_count: int,
+ expected_output: str | None,
+ retry_once: bool,
+) -> None:
+ attempts = 0
+
+ def execute(**_kwargs: object) -> dict[str, str]:
+ nonlocal attempts
+ attempts += 1
+ if attempts == 1:
+ raise CodeExecutionError("source code failed")
+ return {"output": "retried output"}
+
+ monkeypatch.setattr("core.workflow.node_factory.CodeExecutor.execute_workflow_code_template", execute)
+ source = _workflow_tool_source()
+ source_graph = dict(source.graph_config)
+ source_graph["nodes"].insert(
+ 1,
+ {
+ "id": "source-code",
+ "data": {
+ "type": "code",
+ "title": "Code",
+ "code_language": "python3",
+ "code": "def main(): return {'output': 'retried output'}",
+ "outputs": {"output": {"type": "string"}},
+ "variables": [],
+ "error_strategy": child_error_strategy,
+ "default_value": [{"key": "output", "type": "string", "value": "child fallback"}],
+ "retry_config": {"retry_enabled": retry_once, "max_retries": 1, "retry_interval": 0},
+ },
+ },
+ )
+ source_graph["nodes"][-1]["data"]["outputs"][0]["value_selector"] = ["source-code", "output"]
+ source_graph["edges"] = [
+ {"id": "start-code", "source": "source-start", "target": "source-code"},
+ {"id": "code-end", "source": "source-code", "target": "source-end"},
+ ]
+ repository = {}
+ repository["source-workflow"] = replace(source, graph_config=source_graph)
+ node, _, _ = _workflow_tool_node()
+ node.node_data.error_strategy = tool_error_strategy
+ node.node_data.default_value = [DefaultValue(key="text", type=DefaultValueType.STRING, value="tool fallback")]
+ start = StartNode(
+ node_id="outer-start",
+ data=StartNodeData(title="Start", variables=[]),
+ init_params=node.init_params,
+ runtime_state=node.runtime_state,
+ )
+ end = EndNode(
+ node_id="outer-end",
+ data=EndNodeData.model_validate(
+ {
+ "title": "End",
+ "outputs": [
+ {
+ "variable": "answer",
+ "value_selector": [
+ "tool",
+ "error_message" if tool_error_strategy == ErrorStrategy.FAIL_BRANCH else "text",
+ ],
+ }
+ ],
+ }
+ ),
+ init_params=node.init_params,
+ runtime_state=node.runtime_state,
+ )
+ graph = (
+ Graph.new()
+ .add_root(start)
+ .add_node(node)
+ .add_node(end, source_handle="fail-branch" if tool_error_strategy == ErrorStrategy.FAIL_BRANCH else "source")
+ .build()
+ )
+ persisted: list[NodeEvent] = []
+ engine = Engine(
+ graph=graph,
+ runtime_state=node.runtime_state,
+ command_channel=InMemoryChannel(),
+ workers=1,
+ container_handler_factories=(
+ partial(
+ WorkflowToolContainerHandler,
+ sources=repository,
+ event_listener_factory=lambda *_: persisted.append,
+ ),
+ ),
+ )
+ events: list[EngineEvent] = []
+ if terminal_type is GraphRunFailedEvent:
+ with pytest.raises(RuntimeError, match="source code failed"):
+ events.extend(engine.run())
+ else:
+ events.extend(engine.run())
+
+ assert attempts == (2 if retry_once else 1)
+ assert isinstance(events[-1], terminal_type)
+ assert node.runtime_state.graph_execution.exceptions_count == expected_count
+ if isinstance(events[-1], GraphRunFailedEvent):
+ assert events[-1].error == "source code failed"
+ assert not any(isinstance(event, NodeRunSucceededEvent) and event.node_id == end.id for event in events)
+ else:
+ assert isinstance(events[-1], GraphRunSucceededEvent | GraphRunPartialSucceededEvent)
+ assert events[-1].outputs == {"answer": expected_output}
+ if tool_error_strategy is not None:
+ assert any(isinstance(event, NodeRunExceptionEvent) and event.node_id == node.id for event in events)
+ if isinstance(events[-1], GraphRunFailedEvent | GraphRunPartialSucceededEvent):
+ assert events[-1].exceptions_count == expected_count
+ if child_error_strategy is not None:
+ assert any(isinstance(event, NodeRunExceptionEvent) and event.node_id == "source-code" for event in persisted)
+ if retry_once:
+ assert [event.retry_index for event in persisted if isinstance(event, NodeRunRetryEvent)] == [1]
+ assert any(isinstance(event, NodeRunSucceededEvent) and event.node_id == "source-code" for event in persisted)
+
+
+@pytest.mark.parametrize("outcome", ["paused", "resumed", "abort-on-resume", "stopped"])
+def test_workflow_tool_human_input_pauses_and_resumes_without_duplicate_form(
+ monkeypatch: pytest.MonkeyPatch,
+ mocker: MockerFixture,
+ outcome: str,
+) -> None:
+ command_channel = InMemoryChannel()
+
+ def stop_on_human_input(event: NodeEvent, *, channel: InMemoryChannel, should_stop: bool) -> None:
+ if should_stop and isinstance(event, NodeRunPauseRequestedEvent):
+ channel.send_command(AbortCommand())
+
+ source_repository = {"source-workflow": _human_input_workflow_tool_source()}
+ handler_factory = partial(
+ WorkflowToolContainerHandler,
+ sources=source_repository,
+ hidden_event_listener=partial(stop_on_human_input, channel=command_channel, should_stop=outcome == "stopped"),
+ )
+ form_repository = HumanInputFormRepositoryImpl(
+ tenant_id="tenant", app_id="outer-app", workflow_execution_id="outer-execution"
+ )
+ create_form_spy = mocker.spy(form_repository, "create_form")
+ human_input_app_ids: list[str] = []
+
+ def build_human_input_callback(
+ factory: DifyNodeFactory,
+ *,
+ node_data: HumanInputNodeData,
+ execution_id_getter: Callable[[], str | None],
+ ) -> DifyHITLCallback:
+ human_input_app_ids.append(factory._human_input_runtime._run_context.app_id)
+ return DifyHITLCallback(
+ form_repository=form_repository,
+ node_data=node_data,
+ execution_id_getter=execution_id_getter,
+ )
+
+ monkeypatch.setattr(DifyNodeFactory, "_build_human_input_callback", build_human_input_callback)
+
+ outer_pool = VariablePool()
+ outer_pool.add(system_variable_selector(SystemVariableKey.WORKFLOW_EXECUTION_ID), "outer-execution")
+ outer_pool.add(system_variable_selector(SystemVariableKey.APP_ID), "outer-app")
+ outer_pool.add(system_variable_selector(SystemVariableKey.WORKFLOW_ID), "outer-workflow")
+ initial_state = RuntimeState(
+ workflow_id="outer-workflow",
+ variable_pool=outer_pool,
+ start_at=1,
+ )
+ initial_node, _, _ = _workflow_tool_node(initial_state, app_id="intermediate-app")
+ initial_graph = _outer_graph(initial_node)
+ initial_owner_factory = DifyNodeFactory(
+ initial_node.init_params, initial_state, human_input_run_context=build_test_run_context(app_id="outer-app")
+ )
+ initial_graph.node_factory = initial_owner_factory
+ initial_events = list(
+ Engine(
+ graph=initial_graph,
+ runtime_state=initial_state,
+ command_channel=command_channel,
+ workers=1,
+ container_handler_factories=(handler_factory,),
+ ).run()
+ )
+
+ paused = initial_events[-1]
+ if outcome == "stopped":
+ assert isinstance(paused, GraphRunAbortedEvent)
+ return
+ assert isinstance(paused, GraphRunPausedEvent)
+ assert len(paused.reasons) == 1
+ reason = paused.reasons[0]
+ assert isinstance(reason, HitlRequired)
+ assert reason.node_id == "source-human"
+ assert reason.node_title == "Approval"
+ assert all(
+ not isinstance(event, NodeEvent) or event.node_id not in {"source-start", "source-human"}
+ for event in initial_events
+ )
+ create_form_spy.assert_called_once()
+ create_params = create_form_spy.call_args.args[0]
+ assert create_params.node_id == "source-human"
+ assert create_params.workflow_execution_id == "outer-execution"
+ child_frames = list(initial_state.container_frames())
+ assert len(child_frames) == 1
+ child_execution = initial_state.graph_execution.node_executions[(child_frames[0].frame_id, "source-human")]
+ assert create_params.form_id == child_execution.execution_id
+ assert human_input_app_ids == ["outer-app"]
+
+ if outcome == "paused":
+ return
+
+ restored_state = RuntimeState.from_snapshot(initial_state.dumps())
+ assert (
+ resolve_human_input_node_id(
+ node_id=reason.node_id,
+ form_id=reason.session_id,
+ variable_pool=restored_state.variable_pool,
+ )
+ == "tool"
+ )
+ restored_node, _, _ = _workflow_tool_node(restored_state, app_id="intermediate-app")
+ restored_graph = _outer_graph(restored_node)
+ restored_owner_factory = DifyNodeFactory(
+ restored_node.init_params, restored_state, human_input_run_context=initial_owner_factory.human_input_run_context
+ )
+ restored_graph.node_factory = restored_owner_factory
+ if outcome == "resumed":
+ HumanInputFormSubmissionRepository().mark_submitted(
+ form_id=reason.session_id,
+ recipient_id=None,
+ selected_action_id="approve",
+ form_data={},
+ submission_user_id="user",
+ submission_end_user_id=None,
+ )
+ resumed_channel = InMemoryChannel()
+ resumed_handler_factory = partial(
+ WorkflowToolContainerHandler,
+ sources=source_repository,
+ hidden_event_listener=partial(
+ stop_on_human_input, channel=resumed_channel, should_stop=outcome == "abort-on-resume"
+ ),
+ )
+ resumed_events = list(
+ Engine(
+ graph=restored_graph,
+ runtime_state=restored_state,
+ command_channel=resumed_channel,
+ workers=1,
+ container_handler_factories=(resumed_handler_factory,),
+ ).run()
+ )
+
+ if outcome == "abort-on-resume":
+ assert isinstance(resumed_events[-1], GraphRunAbortedEvent)
+ return
+ assert isinstance(resumed_events[-1], GraphRunSucceededEvent)
+ tool_succeeded = next(
+ event for event in resumed_events if isinstance(event, NodeRunSucceededEvent) and event.node_id == "tool"
+ )
+ assert tool_succeeded.node_run_result.outputs["decision"].to_object() == "approve"
+ assert json.loads(tool_succeeded.node_run_result.outputs["text"].to_object()) == {"decision": "approve"}
+ files = restored_state.variable_pool.get(("tool", "files"))
+ assert files is not None
+ assert files.value_type == SegmentType.ARRAY_FILE
+ assert files.to_object() == []
+ create_form_spy.assert_called_once()
+ assert human_input_app_ids == ["outer-app", "outer-app"]
+ assert all(
+ not isinstance(event, NodeEvent) or event.node_id not in {"source-human", "source-end"}
+ for event in resumed_events
+ )
+ assert list(restored_state.container_frames()) == []
+ assert list(restored_state.container_runs()) == []
diff --git a/api/tests/unit_tests/core/workflow/test_workflow_tool_event_delivery.py b/api/tests/unit_tests/core/workflow/test_workflow_tool_event_delivery.py
new file mode 100644
index 00000000000000..137b69fe621387
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_event_delivery.py
@@ -0,0 +1,446 @@
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import UTC, datetime
+from functools import partial
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.orm import Session, sessionmaker
+
+from clients.agent_backend import FakeAgentBackendRunClient
+from core.app.apps.workflow.app_config_manager import WorkflowAppConfig
+from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
+from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
+from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
+from core.workflow.node_execution_process_data import (
+ WORKFLOW_TOOL_INVOCATION_ID_KEY,
+)
+from core.workflow.node_factory import DifyNodeFactory
+from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingResolver
+from core.workflow.workflow_tool_container_handler import (
+ WorkflowToolContainerHandler,
+ WorkflowToolNestedContainerHandler,
+)
+from enums import WorkflowKind
+from graphon.engine import Engine
+from graphon.engine.command import InMemoryChannel
+from graphon.engine.container_handler.builtin.loop import LoopContainerHandler
+from graphon.engine.event.processor import NodeEventProcessor
+from graphon.engine.event.stream import EventStream
+from graphon.engine.worker import NodeEventTask
+from graphon.engine_events import (
+ GraphRunSucceededEvent,
+ NodeRunStartedEvent,
+ NodeRunSucceededEvent,
+)
+from graphon.engine_events.base import NodeEvent
+from graphon.entities import WorkflowNodeExecution
+from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus, WorkflowType
+from graphon.graph import Graph
+from graphon.node_events import NodeRunResult
+from models import Account, WorkflowRun
+from models.enums import WorkflowRunTriggeredFrom
+from models.model import AppMode
+from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
+from services.workflow_persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
+from services.workflow_run_index import WorkflowRunIndex
+from tests.unit_tests.core.workflow.test_workflow_tool_container import (
+ _container_handler,
+ _outer_graph,
+ _workflow_tool_node,
+)
+from tests.workflow_test_utils import build_test_graph_init_params
+
+
+def test_workflow_tool_loop_break_does_not_escape_into_enclosing_loop(monkeypatch: pytest.MonkeyPatch) -> None:
+ def node(node_id: str, node_type: str, **data: object) -> dict[str, object]:
+ return {"id": node_id, "data": {"type": node_type, "title": node_id, **data}}
+
+ def edge(source: str, target: str, handle: str = "source") -> dict[str, str]:
+ return {"id": f"{source}-{target}", "source": source, "target": target, "sourceHandle": handle}
+
+ tool_node, runtime, _ = _workflow_tool_node()
+ monkeypatch.setattr("core.workflow.node_factory.DifyToolNodeRuntime", lambda _, **_kwargs: runtime)
+ outer_graph = {
+ "nodes": [
+ node("start", "start", variables=[]),
+ node(
+ "outer-loop",
+ "loop",
+ loop_count=3,
+ start_node_id="outer-loop-start",
+ break_conditions=[],
+ logical_operator="and",
+ loop_variables=[
+ {"id": "guard", "label": "guard", "var_type": "number", "value_type": "constant", "value": 0}
+ ],
+ ),
+ node("outer-loop-start", "loop-start", loop_id="outer-loop"),
+ node(
+ "condition",
+ "if-else",
+ loop_id="outer-loop",
+ cases=[
+ {
+ "case_id": "true",
+ "logical_operator": "and",
+ "conditions": [
+ {"comparison_operator": "=", "variable_selector": ["outer-loop", "guard"], "value": "1"}
+ ],
+ }
+ ],
+ ),
+ {"id": "tool", "data": {**tool_node.node_data.model_dump(), "loop_id": "outer-loop"}},
+ node("shared-loop-end", "loop-end", loop_id="outer-loop"),
+ node(
+ "end",
+ "end",
+ outputs=[
+ {"variable": "rounds", "value_selector": ["outer-loop", "loop_round"], "value_type": "number"}
+ ],
+ ),
+ ],
+ "edges": [
+ edge("start", "outer-loop"),
+ edge("outer-loop", "end"),
+ edge("outer-loop-start", "condition"),
+ edge("condition", "tool", "false"),
+ edge("condition", "shared-loop-end", "true"),
+ ],
+ }
+ _, _, _, _, repository = _container_handler()
+ repository["source-workflow"] = replace(
+ repository["source-workflow"],
+ graph_config={
+ "nodes": [
+ node("source-start", "start", variables=[]),
+ node(
+ "source-loop",
+ "loop",
+ loop_count=3,
+ start_node_id="source-loop-start",
+ break_conditions=[],
+ logical_operator="and",
+ ),
+ node("source-loop-start", "loop-start", loop_id="source-loop"),
+ node("shared-loop-end", "loop-end", loop_id="source-loop"),
+ node("source-end", "end", outputs=[]),
+ ],
+ "edges": [
+ edge("source-start", "source-loop"),
+ edge("source-loop", "source-end"),
+ edge("source-loop-start", "shared-loop-end"),
+ ],
+ },
+ )
+ state = tool_node.runtime_state
+ graph = Graph.init(
+ graph_config=outer_graph,
+ node_factory=DifyNodeFactory(
+ init_params=build_test_graph_init_params(workflow_id="outer-workflow", graph_config=outer_graph),
+ runtime_state=state,
+ ),
+ root_node_id="start",
+ )
+ persisted: list[NodeEvent] = []
+ events = list(
+ Engine(
+ graph=graph,
+ runtime_state=state,
+ workers=1,
+ container_handler_factories=(
+ partial(WorkflowToolNestedContainerHandler, handler_factory=LoopContainerHandler),
+ partial(
+ WorkflowToolContainerHandler,
+ sources=repository,
+ event_listener_factory=lambda *_: persisted.append,
+ ),
+ ),
+ ).run()
+ )
+
+ assert isinstance(events[-1], GraphRunSucceededEvent)
+ assert state.outputs == {"rounds": 3}
+ assert [
+ event.node_run_result.outputs["loop_round"].to_object()
+ for event in persisted
+ if isinstance(event, NodeRunSucceededEvent) and event.node_id == "source-loop"
+ ] == [1, 1, 1]
+
+
+def test_workflow_tool_persists_loop_outputs_after_graphon_normalizes_them() -> None:
+ _, frames, state, request, repository = _container_handler()
+ source = repository["source-workflow"]
+ repository["source-workflow"] = replace(
+ source,
+ graph_config={
+ "nodes": [
+ {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}},
+ {
+ "id": "loop",
+ "data": {
+ "type": "loop",
+ "title": "Loop",
+ "loop_count": 1,
+ "start_node_id": "loop-start",
+ "break_conditions": [],
+ "logical_operator": "and",
+ },
+ },
+ {"id": "loop-start", "data": {"type": "loop-start", "title": "Loop start", "loop_id": "loop"}},
+ ],
+ "edges": [{"id": "start-loop", "source": "start", "target": "loop"}],
+ },
+ )
+ persisted: list[NodeEvent] = []
+ handler = WorkflowToolContainerHandler(
+ frames, sources=repository, event_listener_factory=lambda *_: persisted.append
+ )
+ handler.handle_request(invocation_id="invocation", request=request)
+ frame = frames["invocation:workflow-tool"]
+ frame.state.variable_pool.add(("loop", "counter"), 2)
+ processor = NodeEventProcessor(
+ graph_execution=state.graph_execution,
+ event_stream=EventStream(layers=[]),
+ frame_registry=frames,
+ container_handlers={BuiltinNodeTypes.TOOL: handler},
+ )
+ event = NodeRunSucceededEvent(
+ id="loop-execution",
+ node_id="loop",
+ node_type=BuiltinNodeTypes.LOOP,
+ start_at=datetime.now(UTC).replace(tzinfo=None),
+ node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, outputs={"counter": 1}),
+ )
+
+ processor.dispatch(NodeEventTask(frame_id=frame.frame_id, event=event))
+
+ assert {key: value.to_object() for key, value in event.node_run_result.outputs.items()} == {"counter": 2}
+ assert {key: value.to_object() for key, value in persisted[0].node_run_result.outputs.items()} == {"counter": 2}
+ assert persisted[0].container_id == ""
+
+
+@pytest.mark.parametrize("listener_fails", [False, True])
+def test_workflow_tool_delivers_source_events_to_persistence_without_exposing_them(
+ listener_fails: bool, caplog: pytest.LogCaptureFixture
+) -> None:
+ node, _, payload = _workflow_tool_node()
+ source = WorkflowToolSource(
+ app_id=payload.source_app_id,
+ workflow_id=payload.source_workflow_id,
+ graph_config={
+ "nodes": [
+ {"id": "source-start", "data": {"type": "start", "title": "Start", "variables": []}},
+ {"id": "source-end", "data": {"type": "end", "title": "End", "outputs": []}},
+ ],
+ "edges": [{"id": "source-edge", "source": "source-start", "target": "source-end"}],
+ },
+ features_dict={},
+ environment_variables=[],
+ workflow_kind=WorkflowKind.STANDARD,
+ )
+ repository = {}
+ repository["source-workflow"] = source
+ persisted: list[NodeEvent] = []
+
+ def persist_event(event: NodeEvent) -> None:
+ persisted.append(event)
+ if listener_fails:
+ raise RuntimeError("Trace store unavailable")
+
+ listener_requests: list[tuple[WorkflowToolSource, str, str]] = []
+
+ def listener_factory(
+ tool_source: WorkflowToolSource, workflow_id: str, execution_id: str
+ ) -> Callable[[NodeEvent], None]:
+ listener_requests.append((tool_source, workflow_id, execution_id))
+ return persist_event
+
+ events = list(
+ Engine(
+ graph=_outer_graph(node),
+ runtime_state=node.runtime_state,
+ command_channel=InMemoryChannel(),
+ workers=1,
+ container_handler_factories=(
+ partial(
+ WorkflowToolContainerHandler,
+ sources=repository,
+ event_listener_factory=listener_factory,
+ ),
+ ),
+ ).run()
+ )
+
+ assert isinstance(events[-1], GraphRunSucceededEvent)
+ caller_event = next(event for event in events if isinstance(event, NodeRunStartedEvent) and event.node_id == "tool")
+ assert listener_requests == [(source, node.workflow_id, caller_event.id)]
+ assert [event.node_id for event in persisted if isinstance(event, NodeRunStartedEvent)] == [
+ "source-start",
+ "source-end",
+ ]
+ assert [event.node_id for event in persisted if isinstance(event, NodeRunSucceededEvent)] == [
+ "source-start",
+ "source-end",
+ ]
+ assert all(not event.container_id for event in persisted)
+ completed_tool = next(
+ event for event in events if isinstance(event, NodeRunSucceededEvent) and event.node_id == node.id
+ )
+ assert completed_tool.node_run_result.outputs["json"].to_object() == [{}]
+ assert len({event.node_run_result.process_data[WORKFLOW_TOOL_INVOCATION_ID_KEY] for event in persisted}) == 1
+ assert all(event.node_id not in {"source-start", "source-end"} for event in events if isinstance(event, NodeEvent))
+ if listener_fails:
+ assert "Failed to persist Workflow Tool event" in caplog.text
+
+
+@pytest.mark.parametrize("caller_save_fails", [False, True])
+def test_workflow_tool_agent_finds_its_persisted_caller_before_resolving_binding(
+ monkeypatch: pytest.MonkeyPatch,
+ sqlite_session_factory: sessionmaker[Session],
+ caller_save_fails: bool,
+) -> None:
+ binding_resolver = WorkflowAgentBindingResolver()
+ resolution_calls: list[dict[str, object]] = []
+ if caller_save_fails:
+
+ def reject_resolution(**kwargs: object) -> None:
+ resolution_calls.append(kwargs)
+ pytest.fail("Binding resolution must wait for a persisted caller")
+
+ monkeypatch.setattr(binding_resolver, "resolve", reject_resolution)
+ monkeypatch.setattr("core.workflow.node_factory.WorkflowAgentBindingResolver", lambda: binding_resolver)
+ save = SQLAlchemyWorkflowNodeExecutionRepository.save
+
+ def fail_caller_save(
+ repository: SQLAlchemyWorkflowNodeExecutionRepository, execution: WorkflowNodeExecution
+ ) -> None:
+ if execution.node_id == "source-agent" and execution.status == WorkflowNodeExecutionStatus.RUNNING:
+ raise RuntimeError("Caller store unavailable")
+ save(repository, execution)
+
+ monkeypatch.setattr(SQLAlchemyWorkflowNodeExecutionRepository, "save", fail_caller_save)
+ monkeypatch.setattr(
+ "clients.agent_backend.factory.create_agent_backend_run_client", lambda **_kwargs: FakeAgentBackendRunClient()
+ )
+ node, _, payload = _workflow_tool_node()
+ source = WorkflowToolSource(
+ app_id=payload.source_app_id,
+ workflow_id=payload.source_workflow_id,
+ graph_config={
+ "nodes": [
+ {"id": "source-start", "data": {"type": "start", "title": "Start", "variables": []}},
+ {
+ "id": "source-agent",
+ "data": {
+ "type": "agent",
+ "version": "2",
+ "agent_node_kind": "dify_agent",
+ "title": "Unconfigured Agent",
+ "error_strategy": "default-value",
+ "default_value": [],
+ },
+ },
+ ],
+ "edges": [{"id": "source-edge", "source": "source-start", "target": "source-agent"}],
+ },
+ features_dict={},
+ environment_variables=[],
+ workflow_kind=WorkflowKind.STANDARD,
+ )
+ source_repository = {}
+ source_repository["source-workflow"] = source
+ user = Account(name="Test", email="test@example.com")
+ user.id = "user"
+ layer = WorkflowPersistenceLayer(
+ application_generate_entity=WorkflowAppGenerateEntity(
+ task_id="task",
+ app_config=WorkflowAppConfig(
+ app_id="outer-app",
+ tenant_id="tenant",
+ workflow_id="outer-workflow",
+ app_mode=AppMode.WORKFLOW,
+ ),
+ inputs={},
+ files=[],
+ user_id=user.id,
+ stream=True,
+ invoke_from=InvokeFrom.SERVICE_API,
+ extras={},
+ workflow_execution_id="outer-run",
+ ),
+ workflow_info=PersistenceWorkflowInfo(
+ workflow_id="outer-workflow",
+ workflow_type=WorkflowType.WORKFLOW,
+ version="1",
+ graph_data=node.init_params.graph_config,
+ ),
+ workflow_execution_repository=SQLAlchemyWorkflowExecutionRepository(
+ session_factory=sqlite_session_factory,
+ tenant_id="tenant",
+ user=user,
+ app_id="outer-app",
+ triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
+ ),
+ workflow_node_execution_repository=SQLAlchemyWorkflowNodeExecutionRepository(
+ session_factory=sqlite_session_factory,
+ tenant_id="tenant",
+ user=user,
+ app_id="outer-app",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ ),
+ )
+ index = WorkflowRunIndex(())
+ index.seed_source(source.app_id, source.workflow_id, ())
+ layer.set_node_run_indices(index.indices)
+ node.runtime_state.variable_pool.add(("sys", "workflow_run_id"), "outer-run")
+ engine = Engine(
+ graph=_outer_graph(node),
+ runtime_state=node.runtime_state,
+ command_channel=InMemoryChannel(),
+ workers=1,
+ container_handler_factories=(
+ partial(
+ WorkflowToolContainerHandler,
+ sources=source_repository,
+ event_listener_factory=layer.create_workflow_tool_event_listener,
+ ),
+ ),
+ )
+ engine.add_layer(index)
+ engine.add_layer(layer)
+
+ events = list(engine.run())
+
+ assert isinstance(events[-1], GraphRunSucceededEvent)
+ with sqlite_session_factory() as session:
+ execution = session.scalars(
+ select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.node_id == "source-agent")
+ ).one()
+ assert (execution.app_id, execution.workflow_id, execution.workflow_run_id) == (
+ "source-app",
+ "source-workflow",
+ "outer-run",
+ )
+ assert execution.triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ caller = session.scalars(
+ select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.node_id == "tool")
+ ).one()
+ assert (execution.triggered_from_workflow_id, execution.triggered_from_node_execution_id) == (
+ "outer-workflow",
+ caller.node_execution_id,
+ )
+ assert execution.status == "exception"
+ if caller_save_fails:
+ assert resolution_calls == []
+ assert execution.error == "Workflow node execution caller is unavailable"
+ assert execution.outputs_dict["error_type"] == "agent_workflow_node_runtime_error"
+ else:
+ # The real workspace store must see the caller synchronously before
+ # the binding resolver reports this deliberately unconfigured Agent.
+ assert execution.error == "Workflow Agent binding not found for node source-agent."
+ assert execution.outputs_dict["error_type"] == "agent_binding_not_found"
+ run = session.scalars(select(WorkflowRun)).one()
+ assert (run.app_id, run.workflow_id, run.status) == ("outer-app", "outer-workflow", "succeeded")
+ assert not any(isinstance(event, NodeEvent) and event.node_id == "source-agent" for event in events)
diff --git a/api/tests/unit_tests/core/workflow/test_workflow_tool_file_access.py b/api/tests/unit_tests/core/workflow/test_workflow_tool_file_access.py
new file mode 100644
index 00000000000000..9e175003c5c2fc
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_file_access.py
@@ -0,0 +1,212 @@
+from datetime import UTC, datetime
+from functools import partial
+
+import pytest
+from sqlalchemy.orm import Session
+
+from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
+from core.app.file_access import FileAccessScope, bind_file_access_scope
+from core.app.layers.execution_context_layer import ExecutionContextLayer
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
+from core.workflow.node_factory import DifyNodeFactory
+from core.workflow.workflow_tool_container_handler import WorkflowToolContainerHandler
+from enums import WorkflowKind
+from extensions.storage.storage_type import StorageType
+from graphon.engine import Engine
+from graphon.engine.command import InMemoryChannel
+from graphon.engine_events import GraphRunSucceededEvent, NodeRunSucceededEvent
+from graphon.graph import Graph
+from graphon.runtime import RuntimeState, VariablePool
+from graphon.variables.segments import ArrayFileSegment
+from models import ToolFile, UploadFile
+from models.enums import CreatorUserRole
+from tests.unit_tests.core.workflow.test_workflow_tool_container import _workflow_tool_node
+from tests.workflow_test_utils import build_test_run_context
+
+
+@pytest.mark.parametrize("input_kind", ["system_files", "file", "file-list"])
+@pytest.mark.parametrize("container_type", [None, "loop", "iteration"])
+@pytest.mark.parametrize(
+ ("transfer_method", "permission"),
+ [
+ ("local_file", "owned"),
+ ("local_file", "other-user"),
+ ("local_file", "granted"),
+ ("tool_file", "owned"),
+ ("tool_file", "other-user"),
+ ],
+)
+def test_workflow_tool_dispatcher_enforces_file_ownership(
+ sqlite_session: Session,
+ monkeypatch: pytest.MonkeyPatch,
+ input_kind: str,
+ container_type: str | None,
+ transfer_method: str,
+ permission: str,
+) -> None:
+ owner_id = "user" if permission == "owned" else "other-user"
+ stored_file: UploadFile | ToolFile
+ if transfer_method == "local_file":
+ stored_file = UploadFile(
+ tenant_id="tenant",
+ storage_type=StorageType.LOCAL,
+ key="stored-file.txt",
+ name="attachment.txt",
+ size=12,
+ extension="txt",
+ mime_type="text/plain",
+ created_by=owner_id,
+ created_by_role=CreatorUserRole.ACCOUNT if permission == "granted" else CreatorUserRole.END_USER,
+ created_at=datetime.now(UTC),
+ used=False,
+ )
+ else:
+ stored_file = ToolFile(
+ tenant_id="tenant",
+ user_id=owner_id,
+ conversation_id=None,
+ file_key="stored-file.txt",
+ name="attachment.txt",
+ mimetype="text/plain",
+ size=12,
+ )
+ sqlite_session.add(stored_file)
+ sqlite_session.commit()
+ mapping = {"transfer_method": transfer_method, "type": "document", "related_id": stored_file.id}
+ scope = FileAccessScope(
+ tenant_id="tenant",
+ user_id="user",
+ user_from=UserFrom.END_USER,
+ invoke_from=InvokeFrom.SERVICE_API,
+ granted_upload_file_ids=frozenset({stored_file.id}) if permission == "granted" else frozenset(),
+ )
+ with bind_file_access_scope(scope):
+ execution_context_layer = ExecutionContextLayer()
+ runtime_state = RuntimeState(
+ workflow_id="outer-workflow",
+ variable_pool=VariablePool(),
+ start_at=1,
+ )
+ template_node, runtime, payload = _workflow_tool_node(runtime_state)
+ monkeypatch.setattr("core.workflow.node_factory.DifyToolNodeRuntime", lambda _, **_kwargs: runtime)
+ tool_data = template_node.node_data.model_dump(mode="python")
+ nodes = [
+ {"id": "start", "data": {"type": "start", "title": "Start", "variables": list[object]()}},
+ {"id": "tool", "data": tool_data},
+ ]
+ edges = [{"id": "outer", "source": "start", "target": "tool"}]
+ if container_type is not None:
+ tool_data[f"{container_type}_id"] = container_type
+ container_data: dict[str, object] = {
+ "type": container_type,
+ "title": container_type,
+ "start_node_id": "body-start",
+ }
+ if container_type == "loop":
+ container_data.update(loop_count=1, break_conditions=[], logical_operator="and")
+ else:
+ runtime_state.variable_pool.add(("start", "items"), ["one"])
+ container_data.update(
+ iterator_selector=["start", "items"], output_selector=["tool", "text"], output_type="array[string]"
+ )
+ nodes.extend(
+ [
+ {"id": container_type, "data": container_data},
+ {
+ "id": "body-start",
+ "data": {
+ "type": f"{container_type}-start",
+ "title": "Body start",
+ f"{container_type}_id": container_type,
+ },
+ },
+ ]
+ )
+ edges = [
+ {"id": "outer", "source": "start", "target": container_type},
+ {"id": "inner", "source": "body-start", "target": "tool"},
+ ]
+ graph_config = {"nodes": nodes, "edges": edges}
+ node_factory = DifyNodeFactory(
+ init_params=template_node.init_params.model_copy(
+ update={
+ "graph_config": graph_config,
+ "run_context": build_test_run_context(
+ app_id="outer-app", user_from=UserFrom.END_USER, invoke_from=InvokeFrom.SERVICE_API
+ ),
+ }
+ ),
+ runtime_state=runtime_state,
+ )
+ variables: list[dict[str, object]] = []
+ if input_kind == "system_files":
+ payload = payload.model_copy(update={"system_files": [mapping], "inputs": {}})
+ output_selector = ["sys", "files"]
+ else:
+ variables = [
+ {
+ "variable": "attachment",
+ "label": "Attachment",
+ "type": input_kind,
+ "allowed_file_types": ["document"],
+ "allowed_file_upload_methods": ["local_file"],
+ }
+ ]
+ value = mapping if input_kind == "file" else [mapping]
+ payload = payload.model_copy(update={"inputs": {"attachment": value}})
+ output_selector = ["source-start", "attachment"]
+ monkeypatch.setattr(runtime, "build_workflow_tool_container_payload", lambda **_kwargs: payload)
+ source = WorkflowToolSource(
+ app_id="source-app",
+ workflow_id="source-workflow",
+ graph_config={
+ "nodes": [
+ {"id": "source-start", "data": {"type": "start", "title": "Start", "variables": variables}},
+ {
+ "id": "source-end",
+ "data": {
+ "type": "end",
+ "title": "End",
+ "outputs": [{"variable": "accepted", "value_selector": output_selector}],
+ },
+ },
+ ],
+ "edges": [{"id": "source-edge", "source": "source-start", "target": "source-end"}],
+ },
+ features_dict={},
+ environment_variables=[],
+ workflow_kind=WorkflowKind.STANDARD,
+ )
+ sources = {source.workflow_id: source}
+ engine = Engine(
+ graph=Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start"),
+ runtime_state=runtime_state,
+ command_channel=InMemoryChannel(),
+ workers=1,
+ container_handler_factories=(
+ partial(
+ WorkflowToolContainerHandler,
+ sources=sources,
+ execution_context_factory=execution_context_layer.enter_context,
+ ),
+ ),
+ )
+ engine.add_layer(execution_context_layer)
+
+ if permission == "other-user":
+ expected_error = (
+ "Invalid upload file" if transfer_method == "local_file" else f"ToolFile {stored_file.id} not found"
+ )
+ with pytest.raises(RuntimeError, match=expected_error):
+ list(engine.run())
+ else:
+ events = list(engine.run())
+ assert isinstance(events[-1], GraphRunSucceededEvent)
+ tool_completed = next(
+ event for event in events if isinstance(event, NodeRunSucceededEvent) and event.node_id == "tool"
+ )
+ files_output = tool_completed.node_run_result.outputs["files"]
+ assert isinstance(files_output, ArrayFileSegment)
+ accepted_files = files_output.value
+ assert len(accepted_files) == 1
+ assert accepted_files[0].filename == stored_file.name
diff --git a/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py b/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py
index 14a728a837fcee..5e94f0b0132b36 100644
--- a/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py
+++ b/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py
@@ -1,16 +1,209 @@
import datetime
-import json
+import sqlite3
import time
from collections.abc import Generator
+from contextlib import closing
from unittest.mock import MagicMock, patch
import pytest
+from aliyun.log import GetLogsRequest, GetLogsResponse
+from pytest_mock import MockerFixture
+from extensions.logstore.aliyun_logstore import AliyunLogStore
+from extensions.logstore.aliyun_logstore_pg import AliyunLogStorePG
from extensions.logstore.repositories.logstore_api_workflow_node_execution_repository import (
LogstoreAPIWorkflowNodeExecutionRepository,
_dict_to_workflow_node_execution_model,
)
-from models.workflow import WorkflowNodeExecutionModel
+from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
+
+
+@pytest.fixture(params=["pg", "sdk"])
+def sql_repository(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture):
+ """Exercise the real protocol adapter while executing its SQL against local records."""
+ monkeypatch.setattr(AliyunLogStore, "_instance", None)
+ monkeypatch.setattr(AliyunLogStore, "_initialized", False)
+ monkeypatch.setenv("ALIYUN_SLS_ENDPOINT", "localhost")
+ mocker.patch.object(AliyunLogStore, "_check_endpoint_connectivity")
+ store = AliyunLogStore()
+ store._use_pg_protocol = request.param == "pg"
+ store._pg_client = AliyunLogStorePG("key", "secret", "localhost", "test")
+ store.project_name = "test"
+ store.log_enabled = False
+ with closing(sqlite3.connect(":memory:")) as database:
+ database.row_factory = sqlite3.Row
+
+ def execute(sql: str, *_args: object) -> list[dict[str, object]]:
+ return [dict(row) for row in database.execute(sql)]
+
+ def get_logs(log_request: GetLogsRequest):
+ search, separator, sql = log_request.get_query().partition(" | ")
+ assert separator
+ assert search
+ assert log_request.get_from() == 0
+ assert log_request.get_to() <= int(time.time())
+ return GetLogsResponse({"meta": {"progress": "Complete"}, "data": execute(sql)}, {})
+
+ mocker.patch.object(store._pg_client, "execute_sql", side_effect=execute)
+ mocker.patch.object(store.client, "get_logs", side_effect=get_logs)
+ repository = LogstoreAPIWorkflowNodeExecutionRepository(session_maker=None)
+ yield repository, database
+
+
+def test_sql_history_excludes_recursive_tool_executions_and_retains_legacy_rows(sql_repository) -> None:
+ repository, database = sql_repository
+ rows = [
+ {
+ "id": execution_id,
+ "tenant_id": "tenant",
+ "app_id": "app",
+ "workflow_id": "workflow",
+ "workflow_run_id": "run",
+ "node_id": "same-node",
+ "triggered_from": origin.value if origin is not None else None,
+ "status": "succeeded",
+ "index": index,
+ "created_at": index,
+ "log_version": 1,
+ "__time__": 1,
+ }
+ for execution_id, index, origin in (
+ ("root", 1, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN),
+ ("legacy", 2, None),
+ ("child", 3, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL),
+ )
+ ]
+ database.execute(
+ "CREATE TABLE workflow_node_execution (id TEXT, tenant_id TEXT, app_id TEXT, workflow_id TEXT, "
+ 'workflow_run_id TEXT, node_id TEXT, triggered_from TEXT, status TEXT, "index" INTEGER, '
+ "created_at INTEGER, log_version INTEGER, __time__ INTEGER)"
+ )
+ insert = (
+ "INSERT INTO workflow_node_execution VALUES "
+ "(:id, :tenant_id, :app_id, :workflow_id, :workflow_run_id, :node_id, :triggered_from, :status, "
+ ":index, :created_at, :log_version, :__time__)"
+ )
+ database.executemany(insert, rows)
+ history = repository.get_executions_by_workflow_run(tenant_id="tenant", app_id="app", workflow_run_id="run")
+ # A newer paused/hidden version must not expose the earlier visible version.
+ rows = [
+ {**rows[0], "id": "paused", "created_at": 10, "status": "running"},
+ {**rows[0], "id": "paused", "created_at": 10, "status": "paused", "log_version": 2},
+ {**rows[0], "id": "hidden", "created_at": 11},
+ {**rows[2], "id": "hidden", "created_at": 11, "log_version": 2},
+ {**rows[1], "id": "null-status", "created_at": 0, "status": None},
+ *[
+ {**rows[0], "id": f"other-{field}", field: "other", "created_at": 99}
+ for field in ("tenant_id", "app_id", "workflow_id", "node_id")
+ ],
+ ]
+ database.executemany(insert, rows)
+ latest = repository.get_node_last_execution(
+ tenant_id="tenant", app_id="app", workflow_id="workflow", node_id="same-node"
+ )
+
+ assert [execution.id for execution in history] == ["legacy", "root"]
+ assert latest is not None
+ assert latest.id == "legacy"
+ assert repository.get_node_last_execution("tenant", "app", "workflow", "unknown") is None
+ database.execute("DELETE FROM workflow_node_execution WHERE id IN ('legacy', 'root')")
+ assert repository.get_node_last_execution("tenant", "app", "workflow", "same-node").id == "null-status"
+
+
+def test_sdk_history_searches_use_owner_scope() -> None:
+ with patch("extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.AliyunLogStore"):
+ repository = LogstoreAPIWorkflowNodeExecutionRepository(session_maker=None)
+ repository.logstore_client = MagicMock(supports_pg_protocol=False)
+ repository.logstore_client.execute_sql.return_value = []
+
+ repository.get_executions_by_workflow_run(tenant_id="tenant", app_id="app", workflow_run_id="run")
+ repository.get_node_last_execution(tenant_id="tenant", app_id="app", workflow_id="workflow", node_id="same-node")
+
+ history_query, latest_query = [call.kwargs for call in repository.logstore_client.execute_sql.call_args_list]
+ assert latest_query["query"] == (
+ 'tenant_id: "tenant" and app_id: "app" and workflow_id: "workflow" and node_id: "same-node"'
+ )
+ assert history_query["query"] == 'tenant_id: "tenant" and app_id: "app" and workflow_run_id: "run"'
+
+
+def test_resumption_snapshots_read_latest_nodes_with_full_owner_scope() -> None:
+ with patch("extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.AliyunLogStore"):
+ repository = LogstoreAPIWorkflowNodeExecutionRepository(session_maker=None)
+ with closing(sqlite3.connect(":memory:")) as database:
+ database.row_factory = sqlite3.Row
+ database.execute(
+ "CREATE TABLE workflow_node_execution (id TEXT, node_execution_id TEXT, tenant_id TEXT, app_id TEXT, "
+ "workflow_id TEXT, workflow_run_id TEXT, triggered_from TEXT, node_id TEXT, node_type TEXT, title TEXT, "
+ '"index" INTEGER, status TEXT, elapsed_time REAL, created_at INTEGER, finished_at INTEGER, '
+ "execution_metadata TEXT, log_version INTEGER, __time__ INTEGER, "
+ "inputs TEXT, process_data TEXT, outputs TEXT)"
+ )
+ base = {
+ "id": "row-id",
+ "node_execution_id": "engine-id",
+ "tenant_id": "tenant",
+ "app_id": "app",
+ "workflow_id": "workflow",
+ "workflow_run_id": "run",
+ "triggered_from": "workflow-run",
+ "node_id": "tool",
+ "node_type": "tool",
+ "title": "Approval",
+ "index": 2,
+ "status": "paused",
+ "elapsed_time": 3.5,
+ "created_at": 100,
+ "finished_at": 104,
+ "execution_metadata": '{"iteration_id":"iteration"}',
+ "log_version": 2,
+ "__time__": 1,
+ "inputs": '{"private":"input"}',
+ "process_data": '{"private":"trace"}',
+ "outputs": '{"private":"output"}',
+ }
+ rows = [base, {**base, "log_version": 1, "status": "running"}]
+ rows.extend(
+ {**base, "id": f"page-{index}", "index": index, "execution_metadata": "[]", "elapsed_time": None}
+ for index in range(3, 1003)
+ )
+ for field in ("tenant_id", "app_id", "workflow_id", "workflow_run_id", "triggered_from"):
+ rows.append({**base, "id": f"other-{field}", field: "other", "index": 99})
+ database.executemany(
+ "INSERT INTO workflow_node_execution VALUES (" + ", ".join(f":{key}" for key in base) + ")", rows
+ )
+
+ def execute_query(*, sql: str, **_kwargs: object) -> list[dict[str, object]]:
+ result = [dict(row) for row in database.execute(sql)]
+ assert all(row.keys().isdisjoint({"inputs", "process_data", "outputs"}) for row in result)
+ return result
+
+ repository.logstore_client.execute_sql.side_effect = execute_query
+ snapshots = repository.get_execution_snapshots_by_workflow_run(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ triggered_from="workflow-run",
+ workflow_run_id="run",
+ )
+ assert len(snapshots) == 1001
+ snapshot = snapshots[0]
+ assert (snapshot.execution_id, snapshot.title, snapshot.index, snapshot.status) == (
+ "engine-id",
+ "Approval",
+ 2,
+ "paused",
+ )
+ assert snapshot.iteration_id == "iteration"
+ assert snapshot.elapsed_time == 3.5
+ assert snapshot.created_at == datetime.datetime(1970, 1, 1, 0, 1, 40)
+ assert snapshots[-1].index == 1002
+ assert snapshots[-1].iteration_id is None
+ assert snapshots[-1].elapsed_time == 4
+ assert repository.logstore_client.execute_sql.call_count == 2
+ query = repository.logstore_client.execute_sql.call_args.kwargs
+ assert (
+ query["query"] == 'tenant_id: "tenant" and app_id: "app" and workflow_run_id: "run" and workflow_id: "workflow"'
+ )
def test_load_full_process_data_returns_logstore_mapping() -> None:
@@ -22,29 +215,33 @@ def test_load_full_process_data_returns_logstore_mapping() -> None:
assert repository.load_full_process_data(execution) == {"__dify_retry_history": [{"retry_index": 1}]}
-def test_get_execution_by_id_keeps_process_data_from_highest_failed_log_version() -> None:
- with patch("extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.AliyunLogStore"):
- repository = LogstoreAPIWorkflowNodeExecutionRepository(session_maker=None)
- repository.logstore_client = MagicMock(supports_pg_protocol=False)
- repository.logstore_client.get_logs.return_value = [
- {
- "id": "execution-1",
- "log_version": "1",
- "process_data": "{}",
- },
- {
- "id": "execution-1",
- "log_version": "2",
- "status": "failed",
- "process_data": json.dumps({"workflow_agent_binding_id": "binding-1"}),
- },
- ]
-
- execution = repository.get_execution_by_id("execution-1")
+def test_get_execution_by_id_keeps_process_data_from_highest_failed_log_version(sql_repository) -> None:
+ repository, database = sql_repository
+ execution_id, tenant_id = "execution'1", 'tenant"1'
+ database.execute(
+ "CREATE TABLE workflow_node_execution "
+ "(id TEXT, tenant_id TEXT, log_version INTEGER, status TEXT, process_data TEXT, __time__ INTEGER)"
+ )
+ database.executemany(
+ "INSERT INTO workflow_node_execution VALUES (?, ?, ?, ?, ?, 1)",
+ [
+ (execution_id, tenant_id, 1, "running", "{}"),
+ (execution_id, tenant_id, 2, "failed", '{"workflow_agent_binding_id":"binding-1"}'),
+ ("other-execution", "other-tenant", 3, "succeeded", "{}"),
+ ],
+ )
+ execution = repository.get_execution_by_id(execution_id, tenant_id)
assert execution is not None
assert execution.status.value == "failed"
assert execution.process_data_dict == {"workflow_agent_binding_id": "binding-1"}
+ if not repository.logstore_client.supports_pg_protocol:
+ sdk_query = repository.logstore_client.client.get_logs.call_args.args[0].get_query()
+ assert sdk_query.startswith('id: "execution\'1" and tenant_id: "tenant\\"1" | ')
+ assert repository.get_execution_by_id(execution_id).id == execution_id
+ assert repository.get_execution_by_id(execution_id, "other-tenant") is None
+ assert repository.get_execution_by_id("unknown") is None
+ assert repository.get_execution_by_id("execution' OR '1'='1") is None
_CREATED_AT = datetime.datetime(2026, 8, 18, 2, 0, 0, tzinfo=datetime.UTC)
diff --git a/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_workflow_node_execution_repository.py b/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_workflow_node_execution_repository.py
index 63d87ba6966142..a882af60039054 100644
--- a/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_workflow_node_execution_repository.py
+++ b/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_workflow_node_execution_repository.py
@@ -1,13 +1,19 @@
from collections.abc import Callable
from unittest.mock import MagicMock, patch
+import pytest
+from sqlalchemy import Engine
from sqlalchemy.orm import Session, sessionmaker
+from core.workflow.node_execution_process_data import WORKFLOW_TOOL_ROOT_APP_ID_KEY
from extensions.logstore.repositories.logstore_workflow_node_execution_repository import (
LogstoreWorkflowNodeExecutionRepository,
)
+from graphon.entities import WorkflowNodeExecution
+from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus
+from libs.datetime_utils import naive_utc_now
from models.account import Account
-from models.workflow import WorkflowNodeExecutionTriggeredFrom
+from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
def _make_account() -> Account:
@@ -16,6 +22,101 @@ def _make_account() -> Account:
return account
+@pytest.mark.parametrize("source_app", ["caller-app", "source-app"])
+def test_workflow_tool_scope_preserves_logstore_and_source_scoped_synchronous_agent_rows(
+ config_overrides: Callable[..., None],
+ sqlite_session_factory: sessionmaker[Session],
+ sqlite_engine: Engine,
+ source_app: str,
+) -> None:
+ config_overrides(LOGSTORE_DUAL_WRITE_ENABLED=False)
+ with patch(
+ "extensions.logstore.repositories.logstore_workflow_node_execution_repository.AliyunLogStore"
+ ) as logstore:
+ caller = LogstoreWorkflowNodeExecutionRepository(
+ session_factory=sqlite_session_factory,
+ tenant_id="tenant-1",
+ user=_make_account(),
+ app_id="caller-app",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+ source = caller.for_workflow_tool(source_app)
+
+ node = WorkflowNodeExecution(
+ id="source-exec",
+ node_execution_id="source-exec",
+ workflow_id="source-workflow",
+ workflow_execution_id="caller-run",
+ index=1,
+ node_id="agent",
+ node_type=BuiltinNodeTypes.AGENT,
+ title="Agent",
+ created_at=naive_utc_now(),
+ process_data={WORKFLOW_TOOL_ROOT_APP_ID_KEY: "caller-app"},
+ )
+ source.save_synchronously(node)
+ with sqlite_session_factory() as session:
+ row = session.get(WorkflowNodeExecutionModel, "source-exec")
+ assert row is not None
+ assert (row.app_id, row.tenant_id, row.created_by, row.workflow_run_id) == (
+ source_app,
+ "tenant-1",
+ "account-1",
+ "caller-run",
+ )
+ assert row.triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+
+ node.status = WorkflowNodeExecutionStatus.SUCCEEDED
+ source.save(node)
+ logstore.return_value.put_log.assert_called_once()
+ payload = dict(logstore.return_value.put_log.call_args.args[1])
+ assert (payload["app_id"], payload["tenant_id"], payload["created_by"], payload["status"]) == (
+ source_app,
+ "tenant-1",
+ "account-1",
+ "succeeded",
+ )
+ assert payload["triggered_from"] == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+
+ caller.save_synchronously(node.model_copy(update={"id": "caller-exec", "node_execution_id": "caller-exec"}))
+ caller.save_synchronously(node.model_copy(update={"id": "legacy-exec", "node_execution_id": "legacy-exec"}))
+ for execution_id in ("foreign-root", "foreign-run", "foreign-tenant"):
+ source.save_synchronously(node.model_copy(update={"id": execution_id, "node_execution_id": execution_id}))
+ with sqlite_session_factory() as session:
+ foreign_root = session.get(WorkflowNodeExecutionModel, "foreign-root")
+ foreign_run = session.get(WorkflowNodeExecutionModel, "foreign-run")
+ foreign_tenant = session.get(WorkflowNodeExecutionModel, "foreign-tenant")
+ assert foreign_root is not None
+ assert foreign_run is not None
+ assert foreign_tenant is not None
+ foreign_root.process_data = '{"workflow_tool_root_app_id": "other-app"}'
+ foreign_run.workflow_run_id = "other-run"
+ foreign_tenant.tenant_id = "other-tenant"
+ session.commit()
+ with sqlite_engine.begin() as connection:
+ connection.exec_driver_sql(
+ "CREATE TABLE workflow_node_execution AS SELECT *, 1 AS log_version FROM workflow_node_executions"
+ )
+ connection.exec_driver_sql("UPDATE workflow_node_execution SET triggered_from = NULL WHERE id = 'legacy-exec'")
+
+ def execute_query(*, sql: str, **_kwargs: object) -> list[dict[str, object]]:
+ with sqlite_engine.connect() as connection:
+ return [
+ dict(row)
+ for row in connection.exec_driver_sql(sql.replace("json_extract_scalar", "json_extract")).mappings()
+ ]
+
+ logstore.return_value.execute_sql.side_effect = execute_query
+ assert {execution.id for execution in caller.get_by_workflow_execution("caller-run")} == {
+ "caller-exec",
+ "legacy-exec",
+ }
+ assert {execution.id for execution in source.get_by_workflow_execution("caller-run")} == {
+ "source-exec",
+ "foreign-root",
+ }
+
+
def test_save_synchronously_writes_sql_when_dual_write_is_disabled(
config_overrides: Callable[..., None], sqlite_session_factory: sessionmaker[Session]
) -> None:
diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_node_execution_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_node_execution_repository.py
new file mode 100644
index 00000000000000..99351b4671a182
--- /dev/null
+++ b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_node_execution_repository.py
@@ -0,0 +1,133 @@
+import json
+from datetime import datetime, timedelta
+from unittest.mock import patch
+from uuid import uuid4
+
+import pytest
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session, sessionmaker
+
+from core.workflow.node_execution_process_data import (
+ WORKFLOW_TOOL_ROOT_APP_ID_KEY,
+)
+from graphon.enums import WorkflowNodeExecutionStatus
+from models.enums import CreatorUserRole
+from models.workflow import (
+ ExecutionOffLoadType,
+ WorkflowNodeExecutionModel,
+ WorkflowNodeExecutionOffload,
+ WorkflowNodeExecutionTriggeredFrom,
+)
+from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowNodeExecutionRepository
+from repositories.factory import DifyAPIRepositoryFactory
+from repositories.sqlalchemy_api_workflow_node_execution_repository import (
+ DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
+)
+from tests.unit_tests.config_override import apply_config_overrides
+
+
+@pytest.mark.parametrize("logstore", [False, True])
+def test_app_deletion_removes_nested_caller_records_and_offloads(
+ sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch, logstore: bool
+) -> None:
+ with sqlite_session_factory.begin() as session:
+ for execution_id, app_id, tenant_id, root_app_id, origin in (
+ ("direct", "root-app", "tenant", None, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN),
+ ("child", "source-app", "tenant", "root-app", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL),
+ ("other-app", "source-app", "tenant", "other-app", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL),
+ (
+ "other-tenant",
+ "source-app",
+ "other-tenant",
+ "root-app",
+ WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+ ),
+ ("wrong-origin", "source-app", "tenant", "root-app", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN),
+ ):
+ session.add(
+ WorkflowNodeExecutionModel(
+ id=execution_id,
+ tenant_id=tenant_id,
+ app_id=app_id,
+ workflow_id="workflow",
+ workflow_run_id="run",
+ node_id="node",
+ node_type="agent",
+ title="Agent",
+ index=1,
+ triggered_from=origin,
+ status=WorkflowNodeExecutionStatus.PAUSED,
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by="account",
+ process_data=json.dumps({WORKFLOW_TOOL_ROOT_APP_ID_KEY: root_app_id}) if root_app_id else None,
+ )
+ )
+ session.add(
+ WorkflowNodeExecutionOffload(
+ tenant_id="tenant",
+ app_id="source-app",
+ node_execution_id="child",
+ type_=ExecutionOffLoadType.PROCESS_DATA,
+ file_id=str(uuid4()),
+ )
+ )
+ repository: DifyAPIWorkflowNodeExecutionRepository
+ if logstore:
+ apply_config_overrides(
+ monkeypatch,
+ API_WORKFLOW_NODE_EXECUTION_REPOSITORY=(
+ "extensions.logstore.repositories.logstore_api_workflow_node_execution_repository."
+ "LogstoreAPIWorkflowNodeExecutionRepository"
+ ),
+ )
+ with patch("extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.AliyunLogStore"):
+ repository = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(sqlite_session_factory)
+ else:
+ repository = DifyAPISQLAlchemyWorkflowNodeExecutionRepository(sqlite_session_factory)
+
+ assert repository.delete_executions_by_app("tenant", "root-app", batch_size=1) == 2
+ with sqlite_session_factory() as session:
+ assert session.get(WorkflowNodeExecutionModel, "direct") is None
+ assert session.get(WorkflowNodeExecutionModel, "child") is None
+ for execution_id in ("other-app", "other-tenant", "wrong-origin"):
+ assert session.get(WorkflowNodeExecutionModel, execution_id) is not None
+ assert session.scalar(select(func.count()).select_from(WorkflowNodeExecutionOffload)) == 0
+
+
+def test_workflow_history_and_last_execution_exclude_recursive_tool_nodes(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ root_id, child_id = str(uuid4()), str(uuid4())
+ with sqlite_session_factory.begin() as session:
+ session.add_all(
+ WorkflowNodeExecutionModel(
+ id=execution_id,
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_run_id="run",
+ node_id="same-node",
+ node_type="code",
+ title="Code",
+ index=index,
+ status=WorkflowNodeExecutionStatus.SUCCEEDED,
+ triggered_from=origin,
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by="account",
+ created_at=datetime(2026, 1, 1) + timedelta(seconds=index),
+ )
+ for execution_id, index, origin in (
+ (root_id, 1, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN),
+ (child_id, 2, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL),
+ )
+ )
+ repository = DifyAPISQLAlchemyWorkflowNodeExecutionRepository(sqlite_session_factory)
+
+ history = repository.get_executions_by_workflow_run(tenant_id="tenant", app_id="app", workflow_run_id="run")
+ latest = repository.get_node_last_execution(
+ tenant_id="tenant", app_id="app", workflow_id="workflow", node_id="same-node"
+ )
+
+ assert [execution.id for execution in history] == [root_id]
+ assert latest is not None
+ assert latest.id == root_id
diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py
index 8ba971a82cd4b6..1c90c27b2579cf 100644
--- a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py
+++ b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py
@@ -92,8 +92,10 @@ def test_build_human_input_required_reason_prefers_standalone_web_app_token() ->
def test_build_human_input_required_reason_falls_back_to_console_token() -> None:
+ reason_model = _build_reason_model()
+ reason_model.node_id = "workflow-tool-node"
reason = _build_human_input_required_reason(
- _build_reason_model(),
+ reason_model,
_build_form_model(),
[
_recipient(RecipientType.BACKSTAGE, "btok"),
@@ -101,7 +103,7 @@ def test_build_human_input_required_reason_falls_back_to_console_token() -> None
],
)
- assert reason.node_id == "node-1"
+ assert reason.node_id == "workflow-tool-node"
assert reason.actions[0].id == "approve"
assert not hasattr(reason, "form_token")
diff --git a/api/tests/unit_tests/repositories/test_workflow_tool_source_repository.py b/api/tests/unit_tests/repositories/test_workflow_tool_source_repository.py
new file mode 100644
index 00000000000000..b5181fae723ab4
--- /dev/null
+++ b/api/tests/unit_tests/repositories/test_workflow_tool_source_repository.py
@@ -0,0 +1,495 @@
+import json
+from functools import partial
+
+import pytest
+from sqlalchemy.orm import Session, sessionmaker
+
+from core.tools.workflow_as_tool.repository import WorkflowToolSource
+from core.workflow.workflow_tool_container_types import WorkflowToolContainerPayload
+from models.model import App, AppMode
+from models.tools import WorkflowToolProvider
+from models.workflow import Workflow, WorkflowKind, WorkflowType
+from repositories.workflow_tool_source_repository import SQLAlchemyWorkflowToolSourceRepository
+
+
+def test_materialized_tool_sources_keep_the_published_version_and_tenant(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ from services.workflow_tool_source_service import WorkflowToolSourceService
+
+ _save_source(sqlite_session_factory)
+ with sqlite_session_factory.begin() as session:
+ provider = WorkflowToolProvider(
+ tenant_id=_TENANT_ID,
+ app_id=_APP_ID,
+ version=_PUBLISHED_VERSION,
+ name="source",
+ label="Source",
+ icon="{}",
+ user_id=_ACCOUNT_ID,
+ description="Source",
+ )
+ session.add(provider)
+ session.flush()
+ provider_id = provider.id
+ graph = {"nodes": [{"data": {"type": "tool", "provider_type": "workflow", "provider_id": provider_id}}]}
+ service = WorkflowToolSourceService(SQLAlchemyWorkflowToolSourceRepository(sqlite_session_factory))
+
+ sources = service.load(tenant_id=_TENANT_ID, graph_config=graph).sources
+ assert set(sources) == {_PUBLISHED_WORKFLOW_ID}
+ assert sources[_PUBLISHED_WORKFLOW_ID].graph_config == _GRAPH
+ assert service.load(tenant_id=_OTHER_TENANT_ID, graph_config=graph).sources == {}
+
+ with sqlite_session_factory.begin() as session:
+ provider = session.get(WorkflowToolProvider, provider_id)
+ assert provider is not None
+ provider.version = "latest-published-version"
+ assert sources[_PUBLISHED_WORKFLOW_ID].graph_config == _GRAPH
+ assert set(service.load(tenant_id=_TENANT_ID, graph_config=graph).sources) == {_LATEST_WORKFLOW_ID}
+
+ suspended = WorkflowToolContainerPayload(
+ source_app_id=_APP_ID,
+ source_workflow_id=_PUBLISHED_WORKFLOW_ID,
+ source_workflow_version=_PUBLISHED_VERSION,
+ call_depth=1,
+ )
+ resumed = service.load(tenant_id=_TENANT_ID, graph_config=graph, suspended_tools=[suspended]).sources
+ assert set(resumed) == {_PUBLISHED_WORKFLOW_ID, _LATEST_WORKFLOW_ID}
+ assert resumed[_PUBLISHED_WORKFLOW_ID].graph_config == _GRAPH
+ with pytest.raises(ValueError, match="source was not found"):
+ service.load(tenant_id=_OTHER_TENANT_ID, graph_config=graph, suspended_tools=[suspended])
+
+
+def test_serialized_tool_snapshot_preserves_definitions_without_current_providers(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ from services.workflow_tool_source_service import WorkflowToolSnapshot, WorkflowToolSourceService
+
+ _save_source(sqlite_session_factory)
+ variables = [
+ {"variable": "count", "type": "number", "label": "Count", "default": 0, "required": False},
+ {"variable": "enabled", "type": "checkbox", "label": "Enabled", "default": False, "required": False},
+ ]
+ with sqlite_session_factory.begin() as session:
+ workflow = session.get(Workflow, _PUBLISHED_WORKFLOW_ID)
+ assert workflow is not None
+ workflow.graph = json.dumps(
+ {"nodes": [{"id": "start", "data": {"type": "start", "title": "Start", "variables": variables}}]}
+ )
+ provider = WorkflowToolProvider(
+ tenant_id=_TENANT_ID,
+ app_id=_APP_ID,
+ version=_PUBLISHED_VERSION,
+ name="source",
+ label="Original",
+ icon="{}",
+ user_id=_ACCOUNT_ID,
+ description="Original description",
+ parameter_configuration=json.dumps(
+ [
+ {"name": variable["variable"], "description": variable["label"], "form": "llm"}
+ for variable in variables
+ ]
+ ),
+ )
+ session.add(provider)
+ session.flush()
+ provider_id = provider.id
+ graph = {"nodes": [{"data": {"type": "tool", "provider_type": "workflow", "provider_id": provider_id}}]}
+ service = WorkflowToolSourceService(SQLAlchemyWorkflowToolSourceRepository(sqlite_session_factory))
+ original = service.load(tenant_id=_TENANT_ID, graph_config=graph)
+ snapshot = WorkflowToolSnapshot.model_validate_json(original.snapshot().model_dump_json())
+ with sqlite_session_factory.begin() as session:
+ provider = session.get(WorkflowToolProvider, provider_id)
+ assert provider is not None
+ provider.version = "latest-published-version"
+ provider.name = "republished"
+ provider.label = "Republished"
+ provider.parameter_configuration = "[]"
+ latest = service.load(tenant_id=_TENANT_ID, graph_config=graph)
+ assert latest.tools[provider_id].workflow_id == _LATEST_WORKFLOW_ID
+ assert latest.tools[provider_id].entity.parameters == []
+ assert service.load(tenant_id=_TENANT_ID, graph_config=graph, snapshot=WorkflowToolSnapshot(tools={})).tools == {}
+
+ for remove_provider in (False, True):
+ if remove_provider:
+ with sqlite_session_factory.begin() as session:
+ provider = session.get(WorkflowToolProvider, provider_id)
+ assert provider is not None
+ session.delete(provider)
+ restored = service.load(tenant_id=_TENANT_ID, graph_config=graph, snapshot=snapshot)
+ tool = restored.tools[provider_id]
+ assert set(restored.sources) == {_PUBLISHED_WORKFLOW_ID}
+ assert tool.workflow_id == _PUBLISHED_WORKFLOW_ID
+ assert tool.label == tool.entity.identity.label.en_US == "Original"
+ assert tool.entity.identity.name == "source"
+ assert [parameter.default for parameter in tool.entity.parameters] == [0, False]
+ assert isinstance(tool.entity.parameters[0].default, int)
+ assert tool.entity.parameters[1].default is False
+ assert restored.snapshot() == snapshot
+ snapshot = WorkflowToolSnapshot.model_validate_json(restored.snapshot().model_dump_json())
+
+ with pytest.raises(ValueError, match="source was not found"):
+ service.load(tenant_id=_OTHER_TENANT_ID, graph_config=graph, snapshot=snapshot)
+ with sqlite_session_factory.begin() as session:
+ workflow = session.get(Workflow, _PUBLISHED_WORKFLOW_ID)
+ assert workflow is not None
+ session.delete(workflow)
+ with pytest.raises(ValueError, match="source was not found"):
+ service.load(tenant_id=_TENANT_ID, graph_config=graph, snapshot=snapshot)
+
+
+def test_saved_provider_aliases_keep_distinct_definitions_for_the_same_source(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ from core.tools.entities.common_entities import I18nObject
+ from core.tools.entities.tool_entities import ToolEntity, ToolIdentity
+ from services.workflow_tool_source_service import (
+ WorkflowToolDefinition,
+ WorkflowToolSnapshot,
+ WorkflowToolSourceService,
+ )
+
+ _save_source(sqlite_session_factory)
+ snapshot = WorkflowToolSnapshot(
+ tools={
+ provider_id: WorkflowToolDefinition(
+ app_id=_APP_ID,
+ workflow_id=_PUBLISHED_WORKFLOW_ID,
+ version=_PUBLISHED_VERSION,
+ label=provider_id,
+ entity=ToolEntity(
+ identity=ToolIdentity(
+ author="author",
+ name=provider_id,
+ provider=provider_id,
+ label=I18nObject(en_US=provider_id, zh_Hans=provider_id),
+ )
+ ),
+ )
+ for provider_id in ("first-provider", "second-provider")
+ }
+ )
+ restored = WorkflowToolSourceService(SQLAlchemyWorkflowToolSourceRepository(sqlite_session_factory)).load(
+ tenant_id=_TENANT_ID,
+ graph_config={},
+ snapshot=WorkflowToolSnapshot.model_validate_json(snapshot.model_dump_json()),
+ )
+ assert set(restored.sources) == {_PUBLISHED_WORKFLOW_ID}
+ assert {provider_id: tool.label for provider_id, tool in restored.tools.items()} == {
+ "first-provider": "first-provider",
+ "second-provider": "second-provider",
+ }
+ assert restored.snapshot() == snapshot
+
+
+_TENANT_ID = "11111111-1111-1111-1111-111111111111"
+_OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222"
+_APP_ID = "33333333-3333-3333-3333-333333333333"
+_PUBLISHED_WORKFLOW_ID = "44444444-4444-4444-4444-444444444444"
+_LATEST_WORKFLOW_ID = "77777777-7777-7777-7777-777777777777"
+_DRAFT_WORKFLOW_ID = "55555555-5555-5555-5555-555555555555"
+_ACCOUNT_ID = "66666666-6666-6666-6666-666666666666"
+_PUBLISHED_VERSION = "published-version"
+
+_GRAPH: dict[str, object] = {
+ "nodes": [{"id": "start", "data": {"type": "start", "title": "Start", "variables": list[object]()}}],
+ "edges": list[object](),
+}
+_FEATURES: dict[str, object] = {"file_upload": {"enabled": False}}
+_LATEST_GRAPH: dict[str, object] = {
+ "nodes": [{"id": "latest-start", "data": {"type": "start", "title": "Start", "variables": list[object]()}}],
+ "edges": list[object](),
+}
+
+
+def _save_source(session_factory: sessionmaker[Session]) -> None:
+ app = App(
+ id=_APP_ID,
+ tenant_id=_TENANT_ID,
+ name="Workflow Tool source",
+ description="",
+ mode=AppMode.WORKFLOW,
+ icon_type=None,
+ icon=None,
+ icon_background=None,
+ enable_site=False,
+ enable_api=False,
+ is_public=False,
+ max_active_requests=None,
+ created_by=_ACCOUNT_ID,
+ )
+ workflows = [
+ Workflow(
+ id=workflow_id,
+ tenant_id=_TENANT_ID,
+ app_id=_APP_ID,
+ type=WorkflowType.WORKFLOW,
+ kind=WorkflowKind.STANDARD,
+ version=version,
+ graph=json.dumps(graph),
+ features=json.dumps(_FEATURES),
+ created_by=_ACCOUNT_ID,
+ environment_variables=[],
+ conversation_variables=[],
+ rag_pipeline_variables=[],
+ )
+ for workflow_id, version, graph in (
+ (_PUBLISHED_WORKFLOW_ID, _PUBLISHED_VERSION, _GRAPH),
+ (_LATEST_WORKFLOW_ID, "latest-published-version", _LATEST_GRAPH),
+ (_DRAFT_WORKFLOW_ID, Workflow.VERSION_DRAFT, _GRAPH),
+ )
+ ]
+ app.workflow_id = _LATEST_WORKFLOW_ID
+ with session_factory.begin() as session:
+ session.add_all((app, *workflows))
+
+
+def test_get_source_returns_requested_workflow_after_a_new_version_is_published(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ _save_source(sqlite_session_factory)
+ repository = SQLAlchemyWorkflowToolSourceRepository(sqlite_session_factory)
+
+ source = repository.get_source(
+ tenant_id=_TENANT_ID,
+ app_id=_APP_ID,
+ workflow_id=_PUBLISHED_WORKFLOW_ID,
+ version=_PUBLISHED_VERSION,
+ )
+
+ assert source == WorkflowToolSource(
+ app_id=_APP_ID,
+ workflow_id=_PUBLISHED_WORKFLOW_ID,
+ graph_config=_GRAPH,
+ features_dict=_FEATURES,
+ environment_variables=(),
+ workflow_kind=WorkflowKind.STANDARD,
+ )
+
+
+@pytest.mark.parametrize(
+ ("tenant_id", "workflow_id", "version"),
+ [
+ pytest.param(_OTHER_TENANT_ID, _PUBLISHED_WORKFLOW_ID, _PUBLISHED_VERSION, id="wrong-tenant"),
+ pytest.param(_TENANT_ID, _PUBLISHED_WORKFLOW_ID, "wrong-version", id="wrong-version"),
+ pytest.param(_TENANT_ID, _DRAFT_WORKFLOW_ID, Workflow.VERSION_DRAFT, id="draft"),
+ ],
+)
+def test_get_source_rejects_unavailable_source(
+ sqlite_session_factory: sessionmaker[Session],
+ tenant_id: str,
+ workflow_id: str,
+ version: str,
+) -> None:
+ _save_source(sqlite_session_factory)
+ repository = SQLAlchemyWorkflowToolSourceRepository(sqlite_session_factory)
+
+ assert (
+ repository.get_source(
+ tenant_id=tenant_id,
+ app_id=_APP_ID,
+ workflow_id=workflow_id,
+ version=version,
+ )
+ is None
+ )
+
+
+@pytest.mark.parametrize(
+ ("stored_kind", "expected_kind"),
+ [(None, WorkflowKind.STANDARD), ("standard", WorkflowKind.STANDARD), ("snippet", WorkflowKind.SNIPPET)],
+)
+def test_get_source_returns_workflow_kind_enum(
+ sqlite_session_factory: sessionmaker[Session], stored_kind: str | None, expected_kind: WorkflowKind
+) -> None:
+ _save_source(sqlite_session_factory)
+ with sqlite_session_factory.begin() as session:
+ workflow = session.get(Workflow, _PUBLISHED_WORKFLOW_ID)
+ assert workflow is not None
+ workflow.kind = None if stored_kind is None else WorkflowKind.value_of(stored_kind)
+
+ source = SQLAlchemyWorkflowToolSourceRepository(sqlite_session_factory).get_source(
+ tenant_id=_TENANT_ID,
+ app_id=_APP_ID,
+ workflow_id=_PUBLISHED_WORKFLOW_ID,
+ version=_PUBLISHED_VERSION,
+ )
+
+ assert source is not None
+ assert source.workflow_kind is expected_kind
+
+
+@pytest.mark.parametrize("allow_human_input", [False, True])
+def test_materialize_recursive_tools_checks_nested_human_input(
+ sqlite_session_factory: sessionmaker[Session],
+ allow_human_input: bool,
+) -> None:
+ from services.workflow_tool_source_service import WorkflowToolSourceService
+
+ _save_source(sqlite_session_factory)
+ with sqlite_session_factory.begin() as session:
+ child_app_id = "88888888-8888-8888-8888-888888888888"
+ session.add(
+ App(
+ id=child_app_id,
+ tenant_id=_TENANT_ID,
+ name="Approval",
+ mode=AppMode.WORKFLOW,
+ enable_site=False,
+ enable_api=False,
+ )
+ )
+ providers = [
+ WorkflowToolProvider(
+ tenant_id=_TENANT_ID,
+ app_id=app_id,
+ version=version,
+ name=name,
+ label=name,
+ icon="{}",
+ user_id=_ACCOUNT_ID,
+ description=name,
+ )
+ for name, app_id, version in (
+ ("parent", _APP_ID, _PUBLISHED_VERSION),
+ ("approval", child_app_id, "latest-published-version"),
+ )
+ ]
+ session.add_all(providers)
+ session.flush()
+ parent_id, child_id = (provider.id for provider in providers)
+
+ def graph(provider_id: str) -> dict[str, list[dict[str, object]]]:
+ return {"nodes": [{"data": {"type": "tool", "provider_type": "workflow", "provider_id": provider_id}}]}
+
+ parent = session.get(Workflow, _PUBLISHED_WORKFLOW_ID)
+ child = session.get(Workflow, _LATEST_WORKFLOW_ID)
+ assert parent is not None
+ assert child is not None
+ child.app_id = child_app_id
+ parent.graph = json.dumps(graph(child_id))
+ child.graph = json.dumps({"nodes": [*graph(parent_id)["nodes"], {"data": {"type": "human-input"}}]})
+ service = WorkflowToolSourceService(SQLAlchemyWorkflowToolSourceRepository(sqlite_session_factory))
+ if allow_human_input:
+ materialized = service.load(tenant_id=_TENANT_ID, graph_config=graph(parent_id), allow_human_input=True)
+ assert set(materialized.sources) == {_PUBLISHED_WORKFLOW_ID, _LATEST_WORKFLOW_ID}
+ with pytest.raises(ValueError, match="Human Input nodes are not supported"):
+ service.load(
+ tenant_id=_TENANT_ID,
+ graph_config=graph(parent_id),
+ allow_human_input=False,
+ snapshot=materialized.snapshot(),
+ )
+ else:
+ with pytest.raises(ValueError, match="Human Input nodes are not supported"):
+ service.load(tenant_id=_TENANT_ID, graph_config=graph(parent_id), allow_human_input=False)
+
+
+@pytest.mark.parametrize("resuming", [False, True])
+def test_running_tool_keeps_materialized_definition_after_republication(
+ sqlite_session_factory: sessionmaker[Session],
+ resuming: bool,
+) -> None:
+ from core.workflow.node_factory import DifyNodeFactory
+ from core.workflow.workflow_tool_container_handler import WorkflowToolContainerHandler
+ from graphon.engine import Engine
+ from graphon.engine_events import NodeRunSucceededEvent
+ from graphon.engine_events.graph import GraphRunSucceededEvent
+ from graphon.graph import Graph
+ from graphon.nodes.tool.entities import ToolNodeData
+ from graphon.runtime import RuntimeState, VariablePool
+ from services.workflow_tool_source_service import WorkflowToolSourceService
+ from tests.workflow_test_utils import build_test_graph_init_params
+
+ _save_source(sqlite_session_factory)
+ with sqlite_session_factory.begin() as session:
+ provider = WorkflowToolProvider(
+ tenant_id=_TENANT_ID,
+ app_id=_APP_ID,
+ version=_PUBLISHED_VERSION,
+ name="source",
+ label="Source",
+ icon="{}",
+ user_id=_ACCOUNT_ID,
+ description="Source",
+ )
+ session.add(provider)
+ session.flush()
+ provider_id = provider.id
+ data = ToolNodeData(
+ title="Tool",
+ provider_id=provider_id,
+ provider_type="workflow",
+ provider_name="source",
+ tool_name="source",
+ tool_label="Source",
+ tool_configurations={},
+ tool_parameters={},
+ )
+ graph = {
+ "nodes": [
+ {"id": "start", "data": {"type": "start", "title": "Start", "variables": list[object]()}},
+ {
+ "id": "loop",
+ "data": {
+ "type": "loop",
+ "title": "Loop",
+ "start_node_id": "loop-start",
+ "loop_count": 1,
+ "break_conditions": list[object](),
+ "logical_operator": "and",
+ },
+ },
+ {"id": "loop-start", "data": {"type": "loop-start", "title": "Loop start", "loop_id": "loop"}},
+ {"id": "tool", "data": {**data.model_dump(mode="json"), "loop_id": "loop"}},
+ ],
+ "edges": [{"source": "start", "target": "loop"}, {"source": "loop-start", "target": "tool"}],
+ }
+ sources = (
+ WorkflowToolSourceService(SQLAlchemyWorkflowToolSourceRepository(sqlite_session_factory))
+ .load(
+ tenant_id=_TENANT_ID,
+ graph_config=graph,
+ suspended_tools=[
+ WorkflowToolContainerPayload(
+ source_app_id=_APP_ID,
+ source_workflow_id=_PUBLISHED_WORKFLOW_ID,
+ source_workflow_version=_PUBLISHED_VERSION,
+ call_depth=1,
+ )
+ ]
+ if resuming
+ else (),
+ )
+ .sources
+ )
+ source = sources[_PUBLISHED_WORKFLOW_ID]
+ assert source.tool is not None
+ state = RuntimeState(workflow_id="caller", variable_pool=VariablePool(), start_at=1)
+ factory = DifyNodeFactory(
+ init_params=build_test_graph_init_params(
+ workflow_id="caller", graph_config=graph, tenant_id=_TENANT_ID, app_id=_APP_ID
+ ),
+ runtime_state=state,
+ workflow_tools={provider_id: source.tool},
+ )
+ completed_nodes = []
+ engine = Engine(
+ graph=Graph.init(graph_config=graph, node_factory=factory, root_node_id="start"),
+ runtime_state=state,
+ workers=1,
+ container_handler_factories=(
+ partial(
+ WorkflowToolContainerHandler, sources=sources, event_listener_factory=lambda *_: completed_nodes.append
+ ),
+ ),
+ )
+ with sqlite_session_factory.begin() as session:
+ provider = session.get(WorkflowToolProvider, provider_id)
+ assert provider is not None
+ provider.version = "latest-published-version"
+ provider.label = "Republished"
+ events = list(engine.run())
+ assert isinstance(events[-1], GraphRunSucceededEvent)
+ assert [event.node_id for event in completed_nodes if isinstance(event, NodeRunSucceededEvent)] == ["start"]
diff --git a/api/tests/unit_tests/services/test_app_storage_cleanup_service.py b/api/tests/unit_tests/services/test_app_storage_cleanup_service.py
index a9da4d71da8600..4a2b3af0ae8560 100644
--- a/api/tests/unit_tests/services/test_app_storage_cleanup_service.py
+++ b/api/tests/unit_tests/services/test_app_storage_cleanup_service.py
@@ -1,3 +1,4 @@
+import json
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -6,11 +7,12 @@
from botocore.exceptions import ClientError
from sqlalchemy.orm import Session
+from core.workflow.node_execution_process_data import WORKFLOW_TOOL_ROOT_APP_ID_KEY
from extensions.storage.clickzetta_volume.clickzetta_volume_storage import ClickZettaVolumeStorage
from extensions.storage.huawei_obs_storage import HuaweiObsStorage
from extensions.storage.storage_type import StorageType
from graphon.entities.pause_reason import PauseReasonType
-from graphon.enums import WorkflowExecutionStatus
+from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
from graphon.file import FileTransferMethod, FileType
from models import WorkflowType
from models.enums import (
@@ -22,11 +24,25 @@
)
from models.model import AppMode, Conversation, EndUser, Message, MessageFile, UploadFile
from models.tools import ToolFile
-from models.workflow import WorkflowNodeExecutionOffload, WorkflowPause, WorkflowPauseReason, WorkflowRun
+from models.workflow import (
+ WorkflowNodeExecutionModel,
+ WorkflowNodeExecutionOffload,
+ WorkflowNodeExecutionTriggeredFrom,
+ WorkflowPause,
+ WorkflowPauseReason,
+ WorkflowRun,
+)
from services import app_storage_cleanup_service as cleanup
-def _offload(session: Session, app_id: str, key: str, tenant_id: str = "tenant-1") -> tuple[str, str]:
+def _offload(
+ session: Session,
+ app_id: str,
+ key: str,
+ tenant_id: str = "tenant-1",
+ *,
+ node_execution_id: str | None = None,
+) -> tuple[str, str]:
file = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.LOCAL,
@@ -45,7 +61,7 @@ def _offload(session: Session, app_id: str, key: str, tenant_id: str = "tenant-1
offload = WorkflowNodeExecutionOffload(
tenant_id=tenant_id,
app_id=app_id,
- node_execution_id=None,
+ node_execution_id=node_execution_id,
type_=ExecutionOffLoadType.OUTPUTS,
file_id=file.id,
)
@@ -54,6 +70,145 @@ def _offload(session: Session, app_id: str, key: str, tenant_id: str = "tenant-1
return file.id, offload.id
+def _tool_execution(
+ session: Session,
+ *,
+ tenant_id: str = "tenant-1",
+ app_id: str = "source-app",
+ root_app_id: str | None = "app-1",
+ origin: WorkflowNodeExecutionTriggeredFrom = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL,
+) -> WorkflowNodeExecutionModel:
+ execution = WorkflowNodeExecutionModel(
+ tenant_id=tenant_id,
+ app_id=app_id,
+ workflow_id="source-workflow",
+ workflow_run_id="run-1",
+ node_id="nested-node",
+ node_type="code",
+ title="Nested code",
+ index=1,
+ triggered_from=origin,
+ status=WorkflowNodeExecutionStatus.SUCCEEDED,
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by="account-1",
+ process_data=json.dumps({WORKFLOW_TOOL_ROOT_APP_ID_KEY: root_app_id}) if root_app_id else None,
+ )
+ session.add(execution)
+ session.flush()
+ return execution
+
+
+@pytest.fixture
+def deleted_keys(monkeypatch: pytest.MonkeyPatch) -> list[str]:
+ keys: list[str] = []
+ monkeypatch.setattr(cleanup.storage, "delete", keys.append)
+ monkeypatch.setattr(cleanup.storage, "scan", lambda _prefix: [])
+ return keys
+
+
+@pytest.mark.parametrize("legacy_owner", [False, True])
+def test_nested_offload_cleanup_preserves_retry_references(
+ sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, deleted_keys: list[str], legacy_owner: bool
+) -> None:
+ sqlite_session.add(
+ WorkflowRun(
+ id="run-1",
+ tenant_id="tenant-1",
+ app_id="app-1",
+ workflow_id="workflow-1",
+ type=WorkflowType.CHAT,
+ triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
+ version="1",
+ status=WorkflowExecutionStatus.SUCCEEDED,
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by="account-1",
+ )
+ )
+ execution = _tool_execution(sqlite_session, root_app_id=None if legacy_owner else "app-1")
+ file_id, offload_id = _offload(sqlite_session, "source-app", "nested.json", node_execution_id=execution.id)
+
+ def fail_delete(_key: str) -> None:
+ raise RuntimeError("storage unavailable")
+
+ monkeypatch.setattr(cleanup.storage, "delete", fail_delete)
+ with pytest.raises(RuntimeError, match="storage unavailable"):
+ cleanup.cleanup_app_storage("tenant-1", "app-1")
+ sqlite_session.expire_all()
+ assert sqlite_session.get(UploadFile, file_id) is not None
+ assert sqlite_session.get(WorkflowNodeExecutionOffload, offload_id) is not None
+
+ monkeypatch.setattr(cleanup.storage, "delete", deleted_keys.append)
+ cleanup.cleanup_app_storage("tenant-1", "app-1")
+ cleanup.cleanup_app_storage("tenant-1", "app-1")
+ sqlite_session.expire_all()
+ assert deleted_keys == ["nested.json"]
+ assert sqlite_session.get(UploadFile, file_id) is None
+ assert sqlite_session.get(WorkflowNodeExecutionOffload, offload_id) is None
+
+
+@pytest.mark.parametrize(
+ ("tenant_id", "root_app_id", "origin"),
+ [
+ ("tenant-1", "other-root", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL),
+ ("tenant-2", "app-1", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL),
+ ("tenant-1", None, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN),
+ ("tenant-1", "app-1", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN),
+ ],
+)
+def test_nested_offload_cleanup_preserves_other_owners(
+ sqlite_session: Session,
+ deleted_keys: list[str],
+ tenant_id: str,
+ root_app_id: str | None,
+ origin: WorkflowNodeExecutionTriggeredFrom,
+) -> None:
+ execution = _tool_execution(sqlite_session, tenant_id=tenant_id, root_app_id=root_app_id, origin=origin)
+ file_id, offload_id = _offload(
+ sqlite_session, "source-app", "foreign.json", tenant_id, node_execution_id=execution.id
+ )
+
+ cleanup.cleanup_app_storage("tenant-1", "app-1")
+
+ sqlite_session.expire_all()
+ assert deleted_keys == []
+ assert sqlite_session.get(UploadFile, file_id) is not None
+ assert sqlite_session.get(WorkflowNodeExecutionOffload, offload_id) is not None
+
+
+@pytest.mark.parametrize("shared_owner", ["same-root", "other-root", "other-source", "other-tenant", "orphan"])
+def test_nested_offload_cleanup_checks_all_shared_references(
+ sqlite_session: Session, deleted_keys: list[str], shared_owner: str
+) -> None:
+ execution = _tool_execution(sqlite_session)
+ file_id, offload_id = _offload(sqlite_session, "source-app", "shared.json", node_execution_id=execution.id)
+ shared_execution = None
+ if shared_owner in {"same-root", "other-root"}:
+ shared_execution = _tool_execution(
+ sqlite_session,
+ app_id="other-source",
+ root_app_id="app-1" if shared_owner == "same-root" else "other-app",
+ )
+ shared = WorkflowNodeExecutionOffload(
+ tenant_id="tenant-2" if shared_owner == "other-tenant" else "tenant-1",
+ app_id="other-source" if shared_owner in {"same-root", "other-root", "other-source"} else "source-app",
+ node_execution_id=shared_execution.id if shared_execution is not None else None,
+ type_=ExecutionOffLoadType.INPUTS,
+ file_id=file_id,
+ )
+ sqlite_session.add(shared)
+ sqlite_session.commit()
+ shared_id = shared.id
+
+ cleanup.cleanup_app_storage("tenant-1", "app-1")
+
+ sqlite_session.expire_all()
+ retained = shared_owner != "same-root"
+ assert set(deleted_keys) == (set() if retained else {"shared.json"})
+ assert sqlite_session.get(WorkflowNodeExecutionOffload, offload_id) is None
+ assert (sqlite_session.get(WorkflowNodeExecutionOffload, shared_id) is not None) == retained
+ assert (sqlite_session.get(UploadFile, file_id) is not None) == retained
+
+
@pytest.fixture
def storage(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
mock = MagicMock()
diff --git a/api/tests/unit_tests/services/workflow/test_node_output_inspector_service.py b/api/tests/unit_tests/services/workflow/test_node_output_inspector_service.py
index 285acab49f5684..fbbc07e8a6b7c4 100644
--- a/api/tests/unit_tests/services/workflow/test_node_output_inspector_service.py
+++ b/api/tests/unit_tests/services/workflow/test_node_output_inspector_service.py
@@ -694,7 +694,9 @@ def test_keeps_latest_execution_per_node_by_index(session_for: SessionFor) -> No
newer = _execution(node_id="agent-1", outputs={"text": "new"}, index=5)
other_tenant = _execution(node_id="agent-1", outputs={"text": "other tenant"}, index=99, tenant_id="tenant-2")
other_run = _execution(node_id="agent-1", outputs={"text": "other run"}, index=100, workflow_run_id="run-2")
- session = session_for(workflow_run=run, executions=[older, newer, other_tenant, other_run])
+ recursive_tool = _execution(node_id="agent-1", outputs={"text": "recursive child"}, index=101)
+ recursive_tool.triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL
+ session = session_for(workflow_run=run, executions=[older, newer, other_tenant, other_run, recursive_tool])
snapshot = service.snapshot_workflow_run(app_model=_app_model(), workflow_run_id="run-1", session=session)
assert snapshot.node_outputs[0].outputs[0].value_preview == "new"
diff --git a/api/tests/unit_tests/services/workflow/test_pipeline_workflow_tools.py b/api/tests/unit_tests/services/workflow/test_pipeline_workflow_tools.py
new file mode 100644
index 00000000000000..2696f03daf6abb
--- /dev/null
+++ b/api/tests/unit_tests/services/workflow/test_pipeline_workflow_tools.py
@@ -0,0 +1,231 @@
+"""Pipeline tool lifecycle through the real Engine and SQLAlchemy repositories."""
+
+import json
+
+import pytest
+from sqlalchemy import Engine, select
+from sqlalchemy.orm import Session
+
+from clients.agent_backend import FakeAgentBackendRunClient, FakeAgentBackendScenario
+from core.app.apps.pipeline.pipeline_runner import PipelineRunner
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
+from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
+from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
+from models import Account
+from models.agent import AgentWorkspace, AgentWorkspaceBinding, WorkflowAgentBindingType
+from models.dataset import Document
+from models.enums import WorkflowRunTriggeredFrom
+from models.human_input import HumanInputForm
+from models.model import App, AppMode
+from models.tools import WorkflowToolProvider
+from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowRun
+from services.agent.workspace_service import AgentWorkspaceService
+from services.workflow_run_agg import WorkflowRunAgg
+from tests.unit_tests.core.app.apps.pipeline.test_pipeline_runner import (
+ _build_app_generate_entity,
+ _build_runner,
+ _document,
+ _persist_scope,
+ _workflow,
+)
+from tests.unit_tests.core.workflow.nodes.agent_v2.test_binding_resolver import _agent, _binding, _snapshot
+from tests.unit_tests.core.workflow.nodes.agent_v2.test_session_store import _install_backend_client
+
+
+def _pipeline_tool_runner(
+ sqlite_engine: Engine, invoke_from: InvokeFrom, source_node_data: dict[str, object]
+) -> tuple[PipelineRunner, SQLAlchemyWorkflowExecutionRepository, SQLAlchemyWorkflowNodeExecutionRepository]:
+ source_graph = {
+ "nodes": [
+ {"id": "source-start", "data": {"type": "start", "title": "Start", "variables": list[object]()}},
+ {"id": "source-node", "data": source_node_data},
+ {"id": "source-end", "data": {"type": "end", "title": "End", "outputs": list[object]()}},
+ ],
+ "edges": [
+ {"source": "source-start", "target": "source-node"},
+ {"source": "source-node", "target": "source-end", "sourceHandle": "source"},
+ ],
+ }
+ with Session(sqlite_engine, expire_on_commit=False) as session:
+ session.add(
+ App(
+ id="source-app",
+ tenant_id="tenant",
+ name="Source",
+ mode=AppMode.WORKFLOW,
+ enable_site=False,
+ enable_api=False,
+ )
+ )
+ session.add(
+ Workflow(
+ id="source-workflow",
+ tenant_id="tenant",
+ app_id="source-app",
+ type="workflow",
+ version="v1",
+ graph=json.dumps(source_graph),
+ features="{}",
+ created_by="user",
+ )
+ )
+ provider = WorkflowToolProvider(
+ tenant_id="tenant",
+ app_id="source-app",
+ user_id="user",
+ version="v1",
+ name="source",
+ label="Source",
+ description="Source",
+ icon="{}",
+ )
+ session.add(provider)
+ session.flush()
+ graph = {
+ "nodes": [
+ {"id": "start", "data": {"type": "start", "title": "Start", "variables": list[object]()}},
+ {
+ "id": "tool",
+ "data": {
+ "type": "tool",
+ "title": "Tool",
+ "provider_id": provider.id,
+ "provider_type": "workflow",
+ "provider_name": "source",
+ "tool_name": "source",
+ "tool_label": "Source",
+ "tool_configurations": dict[str, object](),
+ "tool_parameters": dict[str, object](),
+ },
+ },
+ ],
+ "edges": [{"source": "start", "target": "tool"}],
+ }
+ _, _, workflow = _persist_scope(session, workflow=_workflow(graph=graph), documents=(_document(),))
+ entity = _build_app_generate_entity().model_copy(
+ update={"invoke_from": invoke_from, "document_id": None if invoke_from == InvokeFrom.DEBUGGER else "doc"}
+ )
+ runner = _build_runner(entity, sqlite_engine, workflow)
+ user = Account(name="User", email="pipeline@example.com")
+ user.id = entity.user_id
+ runs = SQLAlchemyWorkflowExecutionRepository(
+ sqlite_engine,
+ "tenant",
+ user,
+ "pipe",
+ WorkflowRunTriggeredFrom.RAG_PIPELINE_DEBUGGING
+ if invoke_from == InvokeFrom.DEBUGGER
+ else WorkflowRunTriggeredFrom.RAG_PIPELINE_RUN,
+ )
+ nodes = SQLAlchemyWorkflowNodeExecutionRepository(
+ sqlite_engine, "tenant", user, "pipe", WorkflowNodeExecutionTriggeredFrom.RAG_PIPELINE_RUN
+ )
+ return runner, runs, nodes
+
+
+@pytest.mark.parametrize("invoke_from", [InvokeFrom.DEBUGGER, InvokeFrom.PUBLISHED_PIPELINE])
+def test_pipeline_rejects_human_input_tool_before_creating_run_or_form(
+ sqlite_engine: Engine, invoke_from: InvokeFrom
+) -> None:
+ human_input = HumanInputNodeData(title="Approval", user_actions=[UserActionConfig(id="approve", title="Approve")])
+ runner, runs, nodes = _pipeline_tool_runner(sqlite_engine, invoke_from, human_input.model_dump(mode="json"))
+
+ with pytest.raises(ValueError, match="Human Input nodes are not supported"):
+ WorkflowRunAgg.run(runner, None, runs, nodes)
+
+ with Session(sqlite_engine) as session:
+ assert session.scalar(select(WorkflowRun)) is None
+ assert session.scalar(select(WorkflowNodeExecutionModel)) is None
+ assert session.scalar(select(HumanInputForm)) is None
+ document = session.get(Document, "doc")
+ assert document is not None
+ if invoke_from == InvokeFrom.PUBLISHED_PIPELINE:
+ assert document.indexing_status == "error"
+ assert document.error == "Human Input nodes are not supported in this execution context."
+ else:
+ assert document.indexing_status == "waiting"
+ assert document.error is None
+
+
+@pytest.mark.parametrize("invoke_from", [InvokeFrom.DEBUGGER, InvokeFrom.PUBLISHED_PIPELINE])
+@pytest.mark.parametrize(
+ ("scenario", "status"),
+ [(FakeAgentBackendScenario.SUCCESS, "succeeded"), (FakeAgentBackendScenario.FAILED, "failed")],
+)
+def test_pipeline_retires_nested_agent_workspace_when_run_ends(
+ sqlite_engine: Engine,
+ monkeypatch: pytest.MonkeyPatch,
+ invoke_from: InvokeFrom,
+ scenario: FakeAgentBackendScenario,
+ status: str,
+) -> None:
+ runner, runs, nodes = _pipeline_tool_runner(
+ sqlite_engine, invoke_from, {"type": "agent", "version": "2", "agent_node_kind": "dify_agent", "title": "Agent"}
+ )
+ with Session(sqlite_engine) as session:
+ agent = _agent(tenant_id="tenant")
+ session.add(agent)
+ session.flush()
+ snapshot = _snapshot(tenant_id="tenant", agent_id=agent.id)
+ session.add(snapshot)
+ session.flush()
+ session.add(
+ _binding(
+ ids={
+ "tenant_id": "tenant",
+ "app_id": "source-app",
+ "workflow_id": "source-workflow",
+ "node_id": "source-node",
+ },
+ agent_id=agent.id,
+ snapshot_id=snapshot.id,
+ binding_type=WorkflowAgentBindingType.INLINE_AGENT,
+ )
+ )
+ session.commit()
+ backend_client = _install_backend_client(monkeypatch)
+ monkeypatch.setattr(
+ "clients.agent_backend.factory.create_agent_backend_run_client",
+ lambda **_kwargs: FakeAgentBackendRunClient(scenario=scenario),
+ )
+ monkeypatch.setattr(
+ "core.workflow.nodes.agent_v2.runtime_request_builder.resolve_model_context_window", lambda **_kwargs: None
+ )
+ collections: list[dict[str, object]] = []
+
+ def collect_task(**kwargs: object) -> None:
+ collections.append(kwargs)
+
+ monkeypatch.setattr("tasks.collect_agent_resources_task.collect_agent_resources.delay", collect_task)
+
+ WorkflowRunAgg.run(runner, None, runs, nodes)
+ list(runner._queue_manager.listen())
+
+ with Session(sqlite_engine) as session:
+ run = session.get(WorkflowRun, runner.application_generate_entity.workflow_execution_id)
+ assert run is not None
+ assert run.status == status
+ agent_execution = session.scalars(
+ select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.node_id == "source-node")
+ ).one()
+ assert agent_execution.status == status
+ workspace = session.scalars(select(AgentWorkspace)).one()
+ binding = session.scalars(select(AgentWorkspaceBinding)).one()
+ assert (workspace.app_id, workspace.owner_id, workspace.status) == ("source-app", run.id, "retired")
+ assert binding.status == "retired"
+ workspace_id, binding_id = workspace.id, binding.id
+ assert collections == [
+ {
+ "tenant_id": "tenant",
+ "workspace_ids": [workspace_id],
+ "binding_ids": [],
+ "home_snapshot_ids": [],
+ "purge_agent_ids": [],
+ }
+ ]
+ AgentWorkspaceService.collect_retired_workspace(tenant_id="tenant", workspace_id=workspace_id)
+ backend_client.destroy_execution_binding_sync.assert_called_once()
+ with Session(sqlite_engine) as session:
+ assert session.get(AgentWorkspace, workspace_id) is None
+ assert session.get(AgentWorkspaceBinding, binding_id) is None
diff --git a/api/tests/unit_tests/services/workflow/test_workflow_run_agg.py b/api/tests/unit_tests/services/workflow/test_workflow_run_agg.py
index bf13b96f284681..64c12fbf217727 100644
--- a/api/tests/unit_tests/services/workflow/test_workflow_run_agg.py
+++ b/api/tests/unit_tests/services/workflow/test_workflow_run_agg.py
@@ -152,6 +152,18 @@ def test_driver_publishes_persisted_start_index_and_success(sqlite_engine: Engin
assert run.status == "succeeded"
+@pytest.mark.parametrize("snapshot", [None, {}, {"tools": []}])
+def test_invalid_saved_tool_snapshot_is_rejected(sqlite_engine: Engine, snapshot: object) -> None:
+ from pydantic import ValidationError
+
+ runner, workflow_repository, node_repository = make_workflow_runner(sqlite_engine)
+ runner.application_generate_entity.extras["workflow_tool_snapshot"] = snapshot
+ with pytest.raises(ValidationError):
+ WorkflowRunAgg.run(runner, None, workflow_repository, node_repository)
+ with Session(sqlite_engine) as session:
+ assert session.scalar(select(WorkflowRun)) is None
+
+
@pytest.fixture
def _local_storage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(storage, "storage_runner", OpenDALStorage(scheme="fs", root=str(tmp_path)), raising=False)
@@ -375,6 +387,189 @@ def test_durable_pause_resumes_with_one_form_completion_and_continuing_indexes(s
assert [(row.node_id, row.index) for row in executions] == [("start", 1), ("approval", 2), ("end", 3)]
+@pytest.mark.usefixtures("_local_storage")
+def test_pause_before_first_tool_call_preserves_its_published_definition(sqlite_engine: Engine) -> None:
+ from core.app.entities.queue_entities import QueueHumanInputFormFilledEvent
+ from core.repositories.human_input_repository import HumanInputFormSubmissionRepository
+ from graphon.nodes.tool.entities import ToolNodeData
+ from models.human_input import HumanInputForm
+ from models.model import App
+ from models.tools import WorkflowToolProvider
+
+ original, workflow_repository, node_repository = make_workflow_runner(sqlite_engine, human_input=True)
+ original_entity = original.application_generate_entity
+ tenant_id = original_entity.app_config.tenant_id
+ source_app_id = str(uuid4())
+ source_workflow_ids = [str(uuid4()), str(uuid4())]
+ with Session(sqlite_engine) as session, session.begin():
+ session.add(
+ App(
+ id=source_app_id,
+ tenant_id=tenant_id,
+ name="Source",
+ mode=AppMode.WORKFLOW,
+ enable_site=False,
+ enable_api=False,
+ )
+ )
+ for workflow_id, version, marker in zip(
+ source_workflow_ids, ("old", "new"), ("original", "republished"), strict=True
+ ):
+ session.add(
+ Workflow(
+ id=workflow_id,
+ app_id=source_app_id,
+ tenant_id=tenant_id,
+ created_by=original_entity.user_id,
+ type="workflow",
+ version=version,
+ features="{}",
+ graph=json.dumps(
+ {
+ "nodes": [
+ {
+ "id": "source-start",
+ "data": {
+ "type": "start",
+ "title": "Start",
+ "variables": [
+ {
+ "variable": "marker",
+ "type": "text-input",
+ "label": "Marker",
+ "default": marker,
+ "required": False,
+ }
+ ],
+ },
+ },
+ {
+ "id": "source-end",
+ "data": {
+ "type": "end",
+ "title": "End",
+ "outputs": [
+ {"variable": "marker", "value_selector": ["source-start", "marker"]}
+ ],
+ },
+ },
+ ],
+ "edges": [{"source": "source-start", "target": "source-end"}],
+ }
+ ),
+ )
+ )
+ provider = WorkflowToolProvider(
+ tenant_id=tenant_id,
+ app_id=source_app_id,
+ version="old",
+ name="source",
+ label="Original source",
+ icon="{}",
+ user_id=original_entity.user_id,
+ description="Source",
+ parameter_configuration='[{"name":"marker","description":"Marker","form":"llm"}]',
+ )
+ session.add(provider)
+ session.flush()
+ provider_id = provider.id
+ graph = dict(original._workflow.graph_dict)
+ graph["nodes"].append(
+ {
+ "id": "tool",
+ "data": {
+ "type": "tool",
+ **ToolNodeData(
+ title="Tool",
+ provider_id=provider_id,
+ provider_type="workflow",
+ provider_name="source",
+ tool_name="source",
+ tool_label="Source",
+ tool_configurations={},
+ tool_parameters={},
+ ).model_dump(mode="json"),
+ },
+ }
+ )
+ graph["nodes"][2]["data"]["outputs"] = [{"variable": "result", "value_selector": ["tool", "json"]}]
+ graph["edges"][1]["target"] = "tool"
+ graph["edges"].append({"source": "tool", "target": "end"})
+ original._workflow.graph = json.dumps(graph)
+ config = PauseStateLayerConfig(sqlite_engine, original_entity.user_id)
+ WorkflowRunAgg.run(original, config, workflow_repository, node_repository)
+ before = [message.event for message in original._queue_manager.listen()]
+ assert isinstance(before[-1], QueueWorkflowPausedEvent)
+ assert not any(isinstance(event, QueueNodeStartedEvent) and event.node_id == "tool" for event in before)
+ with Session(sqlite_engine) as session, session.begin():
+ pause = session.scalar(select(WorkflowPause))
+ form = session.scalar(select(HumanInputForm))
+ provider = session.get(WorkflowToolProvider, provider_id)
+ assert pause is not None
+ assert form is not None
+ assert provider is not None
+ form_id = form.id
+ snapshot = WorkflowResumptionContext.loads(storage.load(pause.state_object_key).decode())
+ provider.version = "new"
+ provider.label = "Republished source"
+ HumanInputFormSubmissionRepository().mark_submitted(
+ form_id=form_id,
+ recipient_id=None,
+ selected_action_id="approve",
+ form_data={"answer": "approved"},
+ submission_user_id=None,
+ submission_end_user_id=None,
+ )
+ entity = snapshot.get_generate_entity().model_copy(update={"task_id": str(uuid4())})
+ assert isinstance(entity, WorkflowAppGenerateEntity)
+ queue = WorkflowAppQueueManager(entity.task_id, entity.user_id, entity.invoke_from, AppMode.WORKFLOW)
+ resumed = WorkflowAppRunner(
+ application_generate_entity=entity,
+ queue_manager=queue,
+ variable_loader=DUMMY_VARIABLE_LOADER,
+ workflow=original._workflow,
+ system_user_id=entity.user_id,
+ graph_runtime_state=RuntimeState.from_snapshot(snapshot.serialized_graph_runtime_state),
+ response_stream_filter=snapshot.get_response_stream_filter(),
+ execution_driver=WorkflowRunAgg.run,
+ )
+ WorkflowRunAgg.run(resumed, config, workflow_repository, node_repository)
+ after = [message.event for message in queue.listen()]
+ assert [event.form_id for event in after if isinstance(event, QueueHumanInputFormFilledEvent)] == [form_id]
+ assert isinstance(after[-1], QueueWorkflowSucceededEvent), after
+ assert after[-1].outputs == {"result": [{"marker": "original"}]}
+ assert entity.extras == original_entity.extras
+ with Session(sqlite_engine) as session:
+ child_workflows = session.scalars(
+ select(WorkflowNodeExecutionModel.workflow_id).where(WorkflowNodeExecutionModel.app_id == source_app_id)
+ ).all()
+ assert child_workflows
+ assert set(child_workflows) == {source_workflow_ids[0]}
+
+ # A new execution resolves the newly published graph and parameter defaults.
+ graph["nodes"] = [node for node in graph["nodes"] if node["id"] != "approval"]
+ graph["edges"] = [{"source": "start", "target": "tool"}, {"source": "tool", "target": "end"}]
+ original._workflow.graph = json.dumps(graph)
+ fresh_entity = original_entity.model_copy(
+ update={"task_id": str(uuid4()), "workflow_execution_id": str(uuid4()), "extras": {}}
+ )
+ fresh_queue = WorkflowAppQueueManager(
+ fresh_entity.task_id, fresh_entity.user_id, fresh_entity.invoke_from, AppMode.WORKFLOW
+ )
+ fresh = WorkflowAppRunner(
+ application_generate_entity=fresh_entity,
+ queue_manager=fresh_queue,
+ variable_loader=DUMMY_VARIABLE_LOADER,
+ workflow=original._workflow,
+ system_user_id=fresh_entity.user_id,
+ execution_driver=WorkflowRunAgg.run,
+ )
+ WorkflowRunAgg.run(fresh, None, workflow_repository, node_repository)
+ fresh_events = [message.event for message in fresh_queue.listen()]
+ assert isinstance(fresh_events[-1], QueueWorkflowSucceededEvent), fresh_events
+ assert fresh_events[-1].outputs == {"result": [{"marker": "republished"}]}
+
+
def test_active_execution_snapshot_failure_is_not_published_as_a_pause(sqlite_engine: Engine) -> None:
from graphon.engine_events import GraphRunFailedEvent, GraphRunPausedEvent
@@ -602,3 +797,15 @@ def persist(aggregate: WorkflowRunAgg, event: GraphRunPausedEvent) -> None:
forms = session.scalars(select(HumanInputForm).where(HumanInputForm.workflow_run_id == run.id)).all()
assert forms
assert all(form.status == HumanInputFormStatus.EXPIRED for form in forms)
+
+
+def test_legacy_caller_rejects_human_input_before_creating_run_or_form(sqlite_engine: Engine) -> None:
+ from models.human_input import HumanInputForm
+
+ runner, workflow_repository, node_repository = make_workflow_runner(sqlite_engine, human_input=True)
+ runner.application_generate_entity.allow_human_input = False
+ with pytest.raises(ValueError, match="Human Input nodes are not supported"):
+ WorkflowRunAgg.run(runner, None, workflow_repository, node_repository)
+ with Session(sqlite_engine) as session:
+ assert session.scalar(select(WorkflowRun)) is None
+ assert session.scalar(select(HumanInputForm)) is None
diff --git a/api/tests/unit_tests/services/workflow/test_workflow_run_index.py b/api/tests/unit_tests/services/workflow/test_workflow_run_index.py
index bec984d4129329..516b0b7c35a683 100644
--- a/api/tests/unit_tests/services/workflow/test_workflow_run_index.py
+++ b/api/tests/unit_tests/services/workflow/test_workflow_run_index.py
@@ -1,10 +1,11 @@
from datetime import datetime
+import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from core.app.app_config.entities import WorkflowUIBasedAppConfig
-from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
+from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, WorkflowAppGenerateEntity
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
from core.workflow.node_factory import DifyNodeFactory
@@ -12,7 +13,7 @@
from graphon.engine import Engine
from graphon.engine_events import NodeRunStartedEvent
from graphon.entities import WorkflowNodeExecution
-from graphon.enums import BuiltinNodeTypes, WorkflowType
+from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowType
from graphon.graph import Graph
from graphon.runtime import RuntimeState, VariablePool
from models import Account, AppMode
@@ -37,7 +38,7 @@ def _history(execution_id: str, index: int) -> WorkflowNodeExecution:
)
-def _engine() -> Engine:
+def _engine(*, invocation_id: str | None = None) -> Engine:
graph_config: dict[str, object] = {
"nodes": [
{"id": "start", "data": {"type": "start", "title": "Start", "variables": []}},
@@ -51,6 +52,7 @@ def _engine() -> Engine:
start_at=0.0,
)
params = build_test_graph_init_params(workflow_id="workflow", graph_config=graph_config)
+ params.run_context[DIFY_RUN_CONTEXT_KEY].workflow_tool_invocation_id = invocation_id
return Engine(
graph=Graph.init(
graph_config=graph_config,
@@ -114,3 +116,34 @@ def test_engine_indices_are_available_before_delivery_and_match_persistence(
with sqlite_session_factory() as session:
stored = {row.id: row.index for row in session.scalars(select(WorkflowNodeExecutionModel))}
assert stored == {"earlier": 7, **started}
+
+
+@pytest.mark.parametrize(
+ "metadata",
+ [None, {WorkflowNodeExecutionMetadataKey.ITERATION_ID: None, WorkflowNodeExecutionMetadataKey.LOOP_ID: None}],
+)
+def test_source_scopes_continue_independently_and_replayed_execution_keeps_its_index(
+ metadata: dict[WorkflowNodeExecutionMetadataKey, object] | None,
+) -> None:
+ history = _history("root-earlier", 9)
+ history.metadata = metadata
+ index = WorkflowRunIndex(root_executions=[history])
+ assert (index.root_snapshots[0].iteration_id, index.root_snapshots[0].loop_id) == ("", "")
+ index.seed_source("app", "workflow", [_history("source-earlier", 3)])
+ first = _engine(invocation_id="first-call")
+ first.add_layer(index)
+ first_events = [event for event in first.run() if isinstance(event, NodeRunStartedEvent)]
+ assert [index.index_for(event.id) for event in first_events] == [4, 5]
+
+ node = first.graph.root_node
+ node.bind_execution_id(first_events[0].id)
+ index.on_node_run_start(node)
+ assert index.index_for(first_events[0].id) == 4
+
+ index.seed_source("app", "workflow", [_history("source-earlier", 3)])
+ for invocation, expected in (("second-call", [6, 7]), (None, [10, 11])):
+ engine = _engine(invocation_id=invocation)
+ engine.add_layer(index)
+ assert [
+ index.index_for(event.id) for event in engine.run() if isinstance(event, NodeRunStartedEvent)
+ ] == expected
diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py
index 434997871a3094..693dcddf73ca25 100644
--- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py
+++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py
@@ -117,10 +117,14 @@ def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest
delete_workflows = MagicMock(side_effect=lambda *_args: events.append("workflows"))
monkeypatch.setattr(remove_app_task_module, "_delete_workflow_agent_node_bindings", delete_bindings)
monkeypatch.setattr(remove_app_task_module, "_delete_app_workflows", delete_workflows)
+ monkeypatch.setattr(
+ remove_app_task_module, "_delete_app_workflow_node_executions", lambda *_args: events.append("executions")
+ )
+ monkeypatch.setattr(remove_app_task_module, "_delete_app_workflow_runs", lambda *_args: events.append("runs"))
remove_app_task_module.remove_app_and_related_data_task.run(tenant_id="tenant-1", app_id="app-1")
- assert events == ["bindings", "workflows"]
+ assert events == ["bindings", "workflows", "executions", "runs"]
delete_bindings.assert_called_once_with("tenant-1", "app-1")
delete_workflows.assert_called_once_with("tenant-1", "app-1")
diff --git a/api/uv.lock b/api/uv.lock
index fa50c20e0eefa3..36977a5b38bf9a 100644
--- a/api/uv.lock
+++ b/api/uv.lock
@@ -1705,7 +1705,7 @@ requires-dist = [
{ name = "gmpy2", specifier = ">=2.3.0,<3.0.0" },
{ name = "google-api-python-client", specifier = ">=2.201.0,<3.0.0" },
{ name = "google-cloud-aiplatform", specifier = ">=2.3.0,<3.0.0" },
- { name = "graphon", specifier = "==0.8.0" },
+ { name = "graphon", git = "https://github.com/langgenius/graphon?rev=4fa22141774dff80e90e4dfa98ea3e6fbf0421bb" },
{ name = "gunicorn", specifier = ">=26.2.0,<27.0.0" },
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
{ name = "httpx-sse", specifier = "==0.4.3" },
@@ -3084,7 +3084,7 @@ httpx = [
[[package]]
name = "graphon"
version = "0.8.0"
-source = { registry = "https://pypi.org/simple" }
+source = { git = "https://github.com/langgenius/graphon?rev=4fa22141774dff80e90e4dfa98ea3e6fbf0421bb#4fa22141774dff80e90e4dfa98ea3e6fbf0421bb" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "defusedxml" },
@@ -3106,10 +3106,6 @@ dependencies = [
{ name = "unstructured", extra = ["docx", "md", "ppt", "pptx"] },
{ name = "webvtt-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f2/82/a2ded2b6a7879e7ee855936db7410014cd41c1c709155a1ccfe07e208633/graphon-0.8.0.tar.gz", hash = "sha256:654ad8f5cb7ff1711cf6b09e68a6afc7fe1e552b2f8306babdb848142d0a6bf7", size = 281821, upload-time = "2026-09-09T20:03:19.606Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0d/8d/64552a2dc8ef4d1b0c43d927301000533be75d6c51664d5a33fb1ead00ff/graphon-0.8.0-py3-none-any.whl", hash = "sha256:183b668cdd2b1b6c39e75e68778994855400c9a8e314e0bda3b59a2b0fe7e8a0", size = 400203, upload-time = "2026-09-09T20:03:18.198Z" },
-]
[[package]]
name = "graphql-core"
diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts
index a59afcf423f561..c77029ddb84807 100644
--- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts
+++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts
@@ -28,7 +28,6 @@ import {
zRoleAlreadyAssignedError,
zTooManyFilesError,
zUnsupportedFileTypeError,
- zWorkflowToolHumanInputNotSupportedError,
} from './zod.gen.ts'
import {
zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath,
@@ -4746,12 +4745,6 @@ export const post69 = oc
})
.input(z.object({ body: zPostWorkspacesCurrentToolProviderWorkflowCreateBody }))
.output(zPostWorkspacesCurrentToolProviderWorkflowCreateResponse)
- .errors({
- workflow_tool_human_input_not_supported: {
- status: 400,
- data: zWorkflowToolHumanInputNotSupportedError,
- },
- })
export const create2 = {
post: post69,
@@ -4814,12 +4807,6 @@ export const post71 = oc
})
.input(z.object({ body: zPostWorkspacesCurrentToolProviderWorkflowUpdateBody }))
.output(zPostWorkspacesCurrentToolProviderWorkflowUpdateResponse)
- .errors({
- workflow_tool_human_input_not_supported: {
- status: 400,
- data: zWorkflowToolHumanInputNotSupportedError,
- },
- })
export const update5 = {
post: post71,
diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts
index 2cb2a9bfd9ca14..9a94dac18aba62 100644
--- a/packages/contracts/generated/api/console/workspaces/types.gen.ts
+++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts
@@ -1252,11 +1252,6 @@ export type WorkflowToolCreatePayload = {
workflow_app_id: string
}
-export type WorkflowToolHumanInputNotSupportedError = ErrorResponse & {
- code: 'workflow_tool_human_input_not_supported'
- status: 400
-}
-
export type WorkflowToolDeletePayload = {
workflow_tool_id: string
}
@@ -8184,7 +8179,6 @@ export type PostWorkspacesCurrentToolProviderWorkflowCreateData = {
}
export type PostWorkspacesCurrentToolProviderWorkflowCreateErrors = {
- 400: WorkflowToolHumanInputNotSupportedError | ErrorResponse
default: ErrorResponse
}
@@ -8274,7 +8268,6 @@ export type PostWorkspacesCurrentToolProviderWorkflowUpdateData = {
}
export type PostWorkspacesCurrentToolProviderWorkflowUpdateErrors = {
- 400: WorkflowToolHumanInputNotSupportedError | ErrorResponse
default: ErrorResponse
}
diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts
index 3bdd608b69e583..4c5bea2610294b 100644
--- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts
+++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts
@@ -1027,18 +1027,6 @@ export const zMcpAuthResponse = z.object({
result: z.literal('success').nullish(),
})
-/**
- * WorkflowToolHumanInputNotSupportedError
- *
- * Workflow with Human Input nodes cannot be published as a workflow tool.
- */
-export const zWorkflowToolHumanInputNotSupportedError = zErrorResponse.and(
- z.object({
- code: z.literal('workflow_tool_human_input_not_supported'),
- status: z.literal(400),
- }),
-)
-
/**
* WorkflowToolDeletePayload
*/
diff --git a/packages/dify-ui/src/combobox/index.stories.tsx b/packages/dify-ui/src/combobox/index.stories.tsx
index f05ed771607858..932746d38db9ed 100644
--- a/packages/dify-ui/src/combobox/index.stories.tsx
+++ b/packages/dify-ui/src/combobox/index.stories.tsx
@@ -686,8 +686,12 @@ export const Default: Story = {
await expect(body.getByRole('option', { name: 'Notion' })).toBeVisible()
})
+ const listbox = body.getByRole('listbox')
await userEvent.keyboard('{ArrowDown}{Enter}')
await expect(input).toHaveValue('Notion')
+ await waitFor(async () => {
+ await expect(listbox).not.toBeInTheDocument()
+ })
},
}
diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx
index e68840e5e8a4c6..a8170acb48d0bf 100644
--- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx
+++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx
@@ -521,19 +521,18 @@ describe('app-publisher sections', () => {
render(
{
render(
{
it('should show the disabled reason for setup and configured workflow tool actions', () => {
const commonProps = {
appId: 'workflow-app',
- appMode: 'workflow' as const,
appURL: 'https://example.com/app',
canViewAccessPoint: true,
disabledFunctionButton: false,
- hasHumanInputNode: false,
hasTriggerNode: false,
onConfigureWorkflowTool: vi.fn(),
publishedAt: Date.now(),
+ showWorkflowTool: true,
workflowToolAvailable: false,
workflowToolIsLoading: false,
workflowToolMessage: 'Workflow tool unavailable',
@@ -643,14 +640,13 @@ describe('app-publisher sections', () => {
const onConfigureWorkflowTool = vi.fn()
const commonProps = {
appId: 'workflow-app',
- appMode: 'workflow' as const,
appURL: 'https://example.com/app',
canViewAccessPoint: true,
disabledFunctionButton: false,
- hasHumanInputNode: false,
hasTriggerNode: false,
onConfigureWorkflowTool,
publishedAt: Date.now(),
+ showWorkflowTool: true,
toolPublished: true,
workflowToolAvailable: true,
}
@@ -686,15 +682,14 @@ describe('app-publisher sections', () => {
it('should keep Access Point and Deploy available for trigger workflows', () => {
render(
{
it('should hide the Access Point publisher entry without view permission', () => {
render(
{
it('should expose unavailable quick links as disabled buttons before the first publish', () => {
render(
{
render(
{
render(
& {
appId: string
- appMode: AppDetailWithSite['mode']
appURL: string
canViewAccessPoint: boolean
disabledFunctionButton: boolean
@@ -22,6 +20,7 @@ type PublisherActionsSectionProps = Pick<
showDeployAction?: boolean
showMarketplaceAction?: boolean
showRunConfig?: boolean
+ showWorkflowTool: boolean
workflowToolIsLoading: boolean
workflowToolMessage?: string
workflowToolOutdated?: boolean
@@ -31,13 +30,11 @@ type PublisherActionsSectionProps = Pick<
export function PublisherActionsSection({
appId,
- appMode,
appURL,
canViewAccessPoint,
disabledFunctionButton,
disabledFunctionTooltip,
handleOpenRunConfig,
- hasHumanInputNode = false,
hasTriggerNode = false,
marketplaceActionDisabled = false,
publishedAt,
@@ -45,6 +42,7 @@ export function PublisherActionsSection({
showDeployAction = false,
showMarketplaceAction = false,
showRunConfig = false,
+ showWorkflowTool,
toolPublished = false,
workflowToolAvailable = true,
workflowToolIsLoading,
@@ -57,7 +55,6 @@ export function PublisherActionsSection({
const hasPublishedVersion = Boolean(publishedAt)
const showOpenWebApp = !hasTriggerNode
- const showWorkflowTool = appMode === 'workflow' && !hasHumanInputNode && !hasTriggerNode
const navigationDisabled = !hasPublishedVersion
const workflowToolDisabled =
!hasPublishedVersion || !workflowToolAvailable || (toolPublished && workflowToolIsLoading)
diff --git a/web/app/components/app/app-publisher/publisher-content/index.tsx b/web/app/components/app/app-publisher/publisher-content/index.tsx
index 0c53cb832d7f4f..97200e34cad51e 100644
--- a/web/app/components/app/app-publisher/publisher-content/index.tsx
+++ b/web/app/components/app/app-publisher/publisher-content/index.tsx
@@ -47,7 +47,6 @@ export function PublisherContent({
debugWithMultipleModel = false,
disabled = false,
draftUpdatedAt,
- hasHumanInputNode = false,
hasTriggerNode = false,
inputs,
missingStartNode = false,
@@ -127,7 +126,6 @@ export function PublisherContent({
appMode: appDetail.mode,
appName: appDetail.name,
appPublished: publish.published,
- hasHumanInputNode,
hasPublishedVersion: publish.hasPublishedVersion,
hasTriggerNode,
inputs,
@@ -213,13 +211,11 @@ export function PublisherContent({
},
actions: {
appId: appDetail.id,
- appMode: appDetail.mode,
appURL,
canViewAccessPoint,
disabledFunctionButton,
disabledFunctionTooltip,
handleOpenRunConfig: workflowLaunch.openDialog,
- hasHumanInputNode,
hasTriggerNode,
marketplaceActionDisabled: !publish.currentPublishedAt,
publishedAt: publish.currentPublishedAt,
@@ -227,6 +223,7 @@ export function PublisherContent({
showDeployAction: supportsMultiEnvironment,
showMarketplaceAction: systemFeatures.enable_creators_platform,
showRunConfig: workflowLaunch.hasHiddenVariables,
+ showWorkflowTool: workflowTool.visible,
toolPublished: workflowTool.published,
workflowToolAvailable: workflowTool.availableForUser,
workflowToolIsLoading: workflowTool.configuration.isLoading,
diff --git a/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts b/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts
index 85c362a9fa2414..43c14bdd29a7c6 100644
--- a/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts
+++ b/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts
@@ -17,7 +17,6 @@ type UseWorkflowToolParams = {
appMode?: AppMode
appName?: string
appPublished: boolean
- hasHumanInputNode: boolean
hasPublishedVersion: boolean
hasTriggerNode: boolean
inputs?: InputVar[]
@@ -38,7 +37,6 @@ export function useWorkflowTool({
appMode,
appName,
appPublished,
- hasHumanInputNode,
hasPublishedVersion,
hasTriggerNode,
inputs,
@@ -52,7 +50,7 @@ export function useWorkflowTool({
const { t } = useTranslation(['workflow'] as const)
const [drawerOpen, setDrawerOpen] = useState(false)
const canManageTools = useCanManageTools()
- const visible = appMode === 'workflow' && !hasHumanInputNode && !hasTriggerNode
+ const visible = appMode === 'workflow' && !hasTriggerNode
const published = Boolean(toolPublished)
const message =
!hasPublishedVersion || !workflowToolAvailable
@@ -100,5 +98,6 @@ export function useWorkflowTool({
message,
openDrawer,
published,
+ visible,
}
}
diff --git a/web/app/components/app/app-publisher/types.ts b/web/app/components/app/app-publisher/types.ts
index 89a7ac588873a4..12c29893b18ed1 100644
--- a/web/app/components/app/app-publisher/types.ts
+++ b/web/app/components/app/app-publisher/types.ts
@@ -40,5 +40,4 @@ export type AppPublisherProps = {
missingStartNode?: boolean
hasTriggerNode?: boolean
startNodeLimitExceeded?: boolean
- hasHumanInputNode?: boolean
}
diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
index e21e43b8b098cb..eb5541ab246519 100644
--- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
+++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
@@ -140,10 +140,6 @@ const FeaturesTrigger = () => {
return deploymentEdition === 'CLOUD' && plan === 'sandbox' && entryCount > 2
}, [nodes, plan, deploymentEdition])
- const hasHumanInputNode = useMemo(() => {
- return nodes.some((node) => node.data.type === BlockEnum.HumanInput)
- }, [nodes])
-
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const handleShowFeatures = useCallback(() => {
@@ -272,7 +268,6 @@ const FeaturesTrigger = () => {
hasTriggerNode,
startNodeLimitExceeded,
publishDisabled: !hasWorkflowNodes || startNodeLimitExceeded || !canReleaseAndVersion,
- hasHumanInputNode,
}}
/>
>