From 64367951c22a3c5c339ec0073f5bae2560109025 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 24 Sep 2026 02:44:52 +0800 Subject: [PATCH 01/16] feat(workflow): execute workflow tools as nested containers Materialize source graphs and tool definitions before execution. Preserve caller identity, typed outputs and nested pause or stop behavior while batching unfinished execution persistence in the upper service. --- api/core/app/apps/workflow/app_generator.py | 5 + api/core/app/apps/workflow_app_runner.py | 16 +- api/core/app/entities/app_invoke_entities.py | 3 + ...lery_workflow_node_execution_repository.py | 40 +- api/core/repositories/factory.py | 12 +- ...hemy_workflow_node_execution_repository.py | 78 +- api/core/tools/errors.py | 7 - api/core/tools/tool_manager.py | 35 +- .../utils/workflow_configuration_sync.py | 9 - api/core/tools/workflow_as_tool/provider.py | 22 +- api/core/tools/workflow_as_tool/repository.py | 22 + api/core/tools/workflow_as_tool/tool.py | 24 +- api/core/workflow/node_execution.py | 9 + .../workflow/node_execution_process_data.py | 53 +- api/core/workflow/node_factory.py | 57 +- api/core/workflow/node_runtime.py | 43 + .../workflow/nodes/agent_v2/agent_node.py | 7 +- .../workflow/nodes/agent_v2/session_store.py | 55 +- .../workflow/nodes/human_input/boundary.py | 14 +- api/core/workflow/workflow_entry.py | 46 +- .../workflow_tool_container_handler.py | 518 ++++++++ .../workflow/workflow_tool_container_types.py | 18 + api/core/workflow/workflow_tool_node.py | 143 +++ ..._api_workflow_node_execution_repository.py | 394 +++---- ...tore_workflow_node_execution_repository.py | 52 +- ...4b912_add_workflow_tool_caller_locators.py | 30 + api/models/workflow.py | 23 + .../api_workflow_node_execution_repository.py | 54 +- .../app/agent_sandbox_repository.py | 4 + ..._api_workflow_node_execution_repository.py | 77 +- .../sqlalchemy_api_workflow_run_repository.py | 2 +- .../workflow_tool_source_repository.py | 86 ++ api/services/agent/workspace_service.py | 49 +- .../tools/workflow_tools_manage_service.py | 7 - .../workflow/node_output_inspector_service.py | 5 +- api/services/workflow_persistence.py | 99 +- api/services/workflow_run_agg.py | 54 + api/services/workflow_run_index.py | 27 +- api/services/workflow_tool_source_service.py | 57 + api/tasks/remove_app_and_related_data_task.py | 2 +- api/tasks/workflow_node_execution_tasks.py | 86 +- .../test_workflow_tools_manage_service.py | 86 +- .../test_workflow_human_input_completion.py | 6 +- .../layers/test_execution_context_layer.py | 42 +- .../app/workflow/test_persistence_layer.py | 442 +++++++ ...lery_workflow_node_execution_repository.py | 64 + ...hemy_workflow_node_execution_repository.py | 81 +- .../test_workflow_node_execution_batch.py | 227 ++++ .../test_workflow_node_execution_callers.py | 162 +++ .../utils/test_workflow_configuration_sync.py | 30 - .../tools/workflow_as_tool/test_provider.py | 6 +- .../core/tools/workflow_as_tool/test_tool.py | 33 +- .../test_workflow_tool_observability.py | 203 ++++ .../nodes/agent_v2/test_agent_node.py | 39 +- .../nodes/agent_v2/test_session_store.py | 311 ++++- .../workflow/test_enrich_pause_reasons.py | 53 +- .../core/workflow/test_node_factory.py | 1 + .../core/workflow/test_node_runtime.py | 47 + .../workflow/test_workflow_entry_helpers.py | 32 +- .../workflow/test_workflow_tool_container.py | 1046 +++++++++++++++++ .../test_workflow_tool_event_delivery.py | 424 +++++++ .../test_workflow_tool_file_access.py | 209 ++++ ..._api_workflow_node_execution_repository.py | 238 +++- ...tore_workflow_node_execution_repository.py | 103 +- ..._api_workflow_node_execution_repository.py | 133 +++ ..._sqlalchemy_api_workflow_run_repository.py | 6 +- .../test_workflow_tool_source_repository.py | 361 ++++++ .../test_node_output_inspector_service.py | 4 +- .../workflow/test_workflow_run_agg.py | 12 + .../workflow/test_workflow_run_index.py | 39 +- .../test_remove_app_and_related_data_task.py | 6 +- .../app-publisher/__tests__/sections.spec.tsx | 41 +- .../built-in-publisher/actions-section.tsx | 9 +- .../app-publisher/publisher-content/index.tsx | 5 +- .../publisher-content/use-workflow-tool.ts | 5 +- web/app/components/app/app-publisher/types.ts | 1 - .../workflow-header/features-trigger.tsx | 5 - 77 files changed, 6190 insertions(+), 666 deletions(-) create mode 100644 api/core/tools/workflow_as_tool/repository.py create mode 100644 api/core/workflow/node_execution.py create mode 100644 api/core/workflow/workflow_tool_container_handler.py create mode 100644 api/core/workflow/workflow_tool_container_types.py create mode 100644 api/core/workflow/workflow_tool_node.py create mode 100644 api/migrations/versions/2026_09_23_2200-a8c3e7f4b912_add_workflow_tool_caller_locators.py create mode 100644 api/repositories/workflow_tool_source_repository.py create mode 100644 api/services/workflow_tool_source_service.py create mode 100644 api/tests/unit_tests/core/repositories/test_workflow_node_execution_batch.py create mode 100644 api/tests/unit_tests/core/repositories/test_workflow_node_execution_callers.py create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/layers/test_workflow_tool_observability.py create mode 100644 api/tests/unit_tests/core/workflow/test_workflow_tool_container.py create mode 100644 api/tests/unit_tests/core/workflow/test_workflow_tool_event_delivery.py create mode 100644 api/tests/unit_tests/core/workflow/test_workflow_tool_file_access.py create mode 100644 api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_node_execution_repository.py create mode 100644 api/tests/unit_tests/repositories/test_workflow_tool_source_repository.py 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..0fc232a70ecc9c 100644 --- a/api/core/repositories/celery_workflow_node_execution_repository.py +++ b/api/core/repositories/celery_workflow_node_execution_repository.py @@ -24,6 +24,7 @@ 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 +95,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 +121,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): """ @@ -162,6 +174,26 @@ 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=[execution.model_dump() 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, + ) + @override def save_execution_data(self, execution: WorkflowNodeExecution) -> None: """`save` already queues the complete inputs, process data, and outputs.""" @@ -189,6 +221,8 @@ def get_by_workflow_execution( """ Retrieve workflow node executions from cache after loading persisted history once. + Resume hydration reads SQL directly when include_paused is requested. + Args: workflow_execution_id: The workflow execution identifier order_config: Optional configuration for ordering results @@ -197,9 +231,11 @@ def get_by_workflow_execution( A sequence of WorkflowNodeExecution instances """ if include_paused: - # Resume hydration must not widen the ordinary runtime cache. + # Resume reads must not widen the ordinary runtime cache. return self._sql_repository.get_by_workflow_execution( - workflow_execution_id, order_config, include_paused=True + workflow_execution_id, + order_config, + include_paused=include_paused, ) try: if workflow_execution_id not in self._database_loaded_workflow_executions: 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..671629cec6aed1 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,53 @@ 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): + 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 + for field in ("triggered_from_workflow_id", "triggered_from_node_execution_id"): + if getattr(db_model, field) is None: + setattr(db_model, field, getattr(existing, field)) + # Cleanup changes terminal state; replacing an offloaded preview would + # leave it inconsistent with the separately owned upload/file reference. + for offload in existing.offload_data: + setattr(db_model, offload.type_.value, getattr(existing, offload.type_.value)) + 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 +469,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 +532,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 +540,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 +581,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..4fb8d4fa070ee5 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,19 @@ 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.""" + runtime_parameters = self.runtime.runtime_parameters if self.runtime else None + merged_parameters = { + **tool_parameters, + **(runtime_parameters or {}), + } + 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 +246,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..a6d38369cf664a 100644 --- a/api/core/workflow/node_execution_process_data.py +++ b/api/core/workflow/node_execution_process_data.py @@ -2,26 +2,47 @@ 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 +def build_workflow_agent_workspace_key( + node_id: str, workflow_agent_binding_id: str, workflow_tool_invocation_id: str | None = None +) -> str: + """Partition repeated Tool calls without splitting loop executions inside a call.""" + workspace_key = f"{node_id}:{workflow_agent_binding_id}" + if workflow_tool_invocation_id is not None: + return f"workflow-tool:{workflow_tool_invocation_id}:{workspace_key}" + return workspace_key -__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", + "build_workflow_agent_workspace_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..79684f61ed6227 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=str(tool.workflow_app_id), + source_workflow_id=str(tool.workflow_id), + source_workflow_version=str(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..755fbd9b938f9d 100644 --- a/api/core/workflow/nodes/agent_v2/session_store.py +++ b/api/core/workflow/nodes/agent_v2/session_store.py @@ -11,10 +11,12 @@ 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, + build_workflow_agent_workspace_key, +) from models.agent import ( AgentConfigVersionKind, - AgentWorkingResourceStatus, - AgentWorkspace, AgentWorkspaceBinding, AgentWorkspaceOwnerType, ) @@ -38,16 +40,19 @@ 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: + owner_scope_key = build_workflow_agent_workspace_key( + node_id, workflow_agent_binding_id, workflow_tool_invocation_id + ) + if conversation_id and workflow_tool_invocation_id is None: return WorkspaceOwnerScope( tenant_id=tenant_id, app_id=app_id, @@ -76,6 +81,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 +93,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 +134,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 +157,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 +167,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 +188,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 +204,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 +216,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 +264,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 +322,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..c27537bd1fab88 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 @@ -114,6 +123,7 @@ def __init__( graph: Graph, call_depth: int, graph_runtime_state: RuntimeState, + workflow_tool_sources: Mapping[str, WorkflowToolSource] | None = None, command_channel: CommandChannel | None = None, response_stream_filter: ResponseStreamFilter | None = None, ) -> None: @@ -123,6 +133,7 @@ def __init__( :param graph: workflow graph :param call_depth: call depth :param graph_runtime_state: pre-created graph runtime state + :param workflow_tool_sources: materialized pinned Workflow Tool definitions :param command_channel: command channel for external control (optional, defaults to InMemoryChannel) :param response_stream_filter: pre-restored filter for resumed runs (optional, defaults to a fresh ResponseStreamFilter for runs with no prior pause) @@ -138,18 +149,43 @@ def __init__( self.command_channel = command_channel self.graph_config = graph_config + self.workflow_tool_sources = dict(workflow_tool_sources or {}) + 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 +196,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 +327,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..544b88523222b9 --- /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.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, + ) + variable_pool.remove((root_node_id,)) + 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": 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..27045b32cef3b9 --- /dev/null +++ b/api/core/workflow/workflow_tool_node.py @@ -0,0 +1,143 @@ +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 + +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, None, None]: + 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, None, None]: + 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, str) and text_output: + yield StreamChunkEvent( + selector=[self._node_id, "text"], + chunk=text_output, + 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..42788c8b485b48 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", @@ -184,6 +198,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", getattr(domain_model, "triggered_from_workflow_id", None) or ""), + ("triggered_from_node_execution_id", getattr(domain_model, "triggered_from_node_execution_id", None) or ""), ( "triggered_from", self._triggered_from.value if hasattr(self._triggered_from, "value") else str(self._triggered_from), @@ -300,6 +316,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.""" @@ -351,9 +385,10 @@ def get_by_workflow_execution( ) -> Sequence[WorkflowNodeExecution]: """ Retrieve all node executions for a workflow execution. - Paused nodes are always included; include_paused accepts the shared resume-read contract. Uses LogStore SQL query with window function to get the latest version of each node execution. This ensures we only get the most recent version of each node execution record. + LogStore already includes paused nodes by default; include_paused is + accepted for the shared resume-read contract without changing that behavior. Args: workflow_execution_id: The workflow execution identifier order_config: Optional configuration for ordering results @@ -379,6 +414,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 = "" @@ -396,10 +438,12 @@ def get_by_workflow_execution( # Build app_id filter for subquery app_id_filter = "" + escaped_app_id = escape_identifier(self._app_id or "") if self._app_id: - escaped_app_id = escape_identifier(self._app_id) app_id_filter = f" AND app_id='{escaped_app_id}'" + owner_filter = f"{origin_filter}{app_id_filter}" + # Use window function to get latest version of each node execution sql = f""" SELECT * FROM ( @@ -407,7 +451,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}' - {app_id_filter} + AND {owner_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/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/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..3903498fa26ec5 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: @@ -93,13 +97,65 @@ def workflow_execution(self) -> WorkflowExecution: def set_node_run_indices(self, indices: Mapping[str, int]) -> None: """Consume the upper layer's indices, allocated before engine event delivery.""" self._node_run_indices = indices + for layer in self._workflow_tool_layers.values(): + layer.set_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: + self._prepare_workflow_tool_layer(tool_layer) + # 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 + + def _prepare_workflow_tool_layer(self, tool_layer: "WorkflowPersistenceLayer") -> None: + tool_layer._workflow_execution = self._get_workflow_execution() + # ------------------------------------------------------------------ # Layer lifecycle # ------------------------------------------------------------------ @@ -187,7 +243,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 +259,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 +279,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 +395,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 +428,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 +478,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 +488,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 +499,20 @@ 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(): + self._prepare_workflow_tool_layer(tool_layer) + 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) + if unfinished: + 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..ca9e2019b39b60 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,14 +48,18 @@ 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.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 WorkflowToolSourceService logger = logging.getLogger(__name__) @@ -80,6 +87,29 @@ 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) + self._entry.workflow_tool_sources.update( + WorkflowToolSourceService(SQLAlchemyWorkflowToolSourceRepository(session_factory.get_session_maker())).load( + tenant_id=self._run_context.tenant_id, + graph_config=self._entry.graph_config, + allow_human_input=( + 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) + ], + ) + ) + node_factory = self._entry.graph_engine.graph.node_factory + if isinstance(node_factory, DifyNodeFactory): + node_factory.workflow_tools.update( + (source.tool.workflow_as_tool_id, source.tool) + for source in self._entry.workflow_tool_sources.values() + if source.tool is not None + ) 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 @@ -122,6 +152,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) 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..6ac95955e3de89 --- /dev/null +++ b/api/services/workflow_tool_source_service.py @@ -0,0 +1,57 @@ +"""Materialize pinned Workflow Tool definitions before execution begins.""" + +from collections.abc import Mapping, Sequence +from typing import Any + +from core.tools.workflow_as_tool.repository import WorkflowToolSource +from core.workflow.workflow_tool_container_types import WorkflowToolContainerPayload +from repositories.workflow_tool_source_repository import SQLAlchemyWorkflowToolSourceRepository + + +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] = (), + ) -> dict[str, WorkflowToolSource]: + sources: dict[str, WorkflowToolSource] = {} + 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) + + 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 Workflow Tools require Engine-managed container execution.") + 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) + if 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.workflow_id not in sources: + pending.append(source.graph_config) + sources[source.workflow_id] = source + return sources 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..e046da658111e0 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,74 @@ 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): + 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 + offloaded_values = { + offload.type_.value: getattr(existing, offload.type_.value) for offload in existing.offload_data + } + _update_node_execution_from_domain(existing, execution) + for field, value in offloaded_values.items(): + setattr(existing, field, value) + 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)) + + @shared_task(queue="workflow_storage", bind=True, max_retries=3, default_retry_delay=60) def save_workflow_node_execution_task( self, @@ -53,7 +122,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 +188,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 +245,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..b39c3c3c157a80 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,39 @@ from __future__ import annotations +from dataclasses import replace from datetime import UTC, datetime, timedelta from types import SimpleNamespace +from unittest.mock import MagicMock 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.processor import NodeEventProcessor +from graphon.engine.event.stream import EventStream +from graphon.engine.frame import ExecutionFrame, FrameRegistry +from graphon.engine.worker import NodeEventTask from graphon.engine_events import ( GraphRunAbortedEvent, GraphRunFailedEvent, GraphRunPartialSucceededEvent, + GraphRunPausedEvent, GraphRunStartedEvent, GraphRunSucceededEvent, + NodeEvent, NodeRunExceptionEvent, NodeRunFailedEvent, NodeRunPauseRequestedEvent, @@ -25,6 +45,7 @@ from graphon.entities.pause_reason import SchedulingPause from graphon.enums import ( BuiltinNodeTypes, + NodeExecutionType, WorkflowExecutionStatus, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus, @@ -33,6 +54,10 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import NodeRunResult 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 @@ -45,6 +70,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 +144,416 @@ 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 + + +def test_workflow_tool_retry_starts_preserve_original_execution_and_attempt_history( + sqlite_session_factory: sessionmaker[Session], +) -> 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") + layer.on_event(GraphRunStartedEvent()) + state = RuntimeState(workflow_id="source-workflow", variable_pool=VariablePool(), start_at=0) + frames = FrameRegistry() + scheduler = MagicMock() + scheduler.process_node_success.return_value = ([], []) + frames.register( + ExecutionFrame( + frame_id=ROOT_FRAME_ID, + graph=MagicMock(nodes={"child": MagicMock(execution_type=NodeExecutionType.EXECUTABLE)}), + state=state, + scheduler=scheduler, + failure_handler=MagicMock(), + ) + ) + event_stream = MagicMock(spec=EventStream) + event_stream.collect.side_effect = listen + 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) + 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}), + ) + ) + + with sqlite_session_factory() as session: + row = session.scalars(select(WorkflowNodeExecutionModel)).one() + assert row.index == 1 + assert row.status == "succeeded" + assert row.outputs_dict == {"ok": True} + 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-paused", "source-a", "source-b", "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, + ) + ) + layer.on_event( + NodeRunPauseRequestedEvent( + id="root-paused", + node_id="root-paused", + node_type=BuiltinNodeTypes.CODE, + reason=SchedulingPause(message="pause"), + ) + ) + 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-paused": "paused", + "source-a": "failed", + "source-b": "failed", + "source-done": "succeeded", + } + assert rows["source-done"].outputs_dict == {"answer": "kept"} + for execution_id in ("root-a", "root-b", "source-a", "source-b"): + assert rows[execution_id].error == "terminal failure" + assert rows[execution_id].finished_at is not None + 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 +984,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 +1001,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 +1030,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..a70669a597bfd2 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,6 +12,7 @@ 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, @@ -65,6 +66,69 @@ def sample_workflow_node_execution(): class TestCeleryWorkflowNodeExecutionRepository: """Test cases for CeleryWorkflowNodeExecutionRepository.""" + 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..227f3802386651 --- /dev/null +++ b/api/tests/unit_tests/core/repositories/test_workflow_node_execution_batch.py @@ -0,0 +1,227 @@ +"""Batch cleanup persists exact records without losing ownership or stored data.""" + +from datetime import datetime +from unittest.mock import Mock +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.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]] = [] + client = Mock() + 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( + "extensions.logstore.repositories.logstore_workflow_node_execution_repository.AliyunLogStore", + Mock(return_value=client, workflow_node_execution_logstore="workflow_node_execution"), + ) + 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": + records = [dict(call.args[1]) for call in client.put_log.call_args_list] + assert {row["id"] for row in records} == {existing.id, missing.id} + assert all(row["status"] == "failed" and row["log_version"] for row in records) + assert all(row["triggered_from_node_execution_id"] == "caller-execution" for row in 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..2a5bba37171963 --- /dev/null +++ b/api/tests/unit_tests/core/repositories/test_workflow_node_execution_callers.py @@ -0,0 +1,162 @@ +"""Caller identity survives storage updates independently of node payloads.""" + +from datetime import datetime +from unittest.mock import Mock +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.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) + client = Mock() + monkeypatch.setattr( + "extensions.logstore.repositories.logstore_workflow_node_execution_repository.AliyunLogStore", + Mock(return_value=client, workflow_node_execution_logstore="workflow_node_execution"), + ) + 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": + 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 = dict(client.put_log.call_args.args[1]) + client.execute_sql.return_value = [row] + restored = repository.get_by_workflow_execution("root-run")[0] + monkeypatch.setattr( + "extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.AliyunLogStore", + Mock(return_value=client, workflow_node_execution_logstore="workflow_node_execution"), + ) + 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/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..df01a997adbf61 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,35 @@ 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, +): + tool = _build_tool() + user = MagicMock(spec=Account) + generate_mock = MagicMock( + return_value={ + "data": { + "status": WorkflowExecutionStatus.PAUSED, + "outputs": {}, + } + } + ) + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: _workflow_stub()) + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: user) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + + with pytest.raises(ToolInvokeError, match="requires Engine-managed container execution"): + list(tool.invoke(MagicMock(spec=Session), "test_user", {})) + + generate_mock.assert_called_once() + + +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 +327,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..17f36c7317117a --- /dev/null +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_workflow_tool_observability.py @@ -0,0 +1,203 @@ +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 +from unittest.mock import MagicMock + +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 +from graphon.entities.base_node_data import BaseNodeData +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, None, None]: + 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, tenant_id="tenant")) + 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[MagicMock, None, None]: + observed.append((current_app.name, g._login_user, request_id.get())) + yield MagicMock() + observed.append((current_app.name, g._login_user, request_id.get())) + yield MagicMock() + + monkeypatch.setattr(node, "run", run_node) + snippets.append(WorkflowEntry._run_node_with_layers(node, tenant_id="tenant")) + + 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/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index 10750315ccfa36..7522b3a22946bc 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 @@ -58,7 +58,7 @@ 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, AgentWorkspaceOwnerType, WorkflowAgentNodeBinding from models.agent_config_entities import ( AgentSoulConfig, AgentSoulModelConfig, @@ -406,6 +406,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 +419,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 +598,41 @@ 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() -> None: + binding_resolver = FakeBindingResolver() + store = FakeSessionStore() + node = _node( + binding_resolver=binding_resolver, + session_store=store, + workflow_tool_invocation_id="tool-call-1", + ) + session = MagicMock() + session.scalar.side_effect = [binding_resolver.binding, binding_resolver.agent, binding_resolver.snapshot] + + with ( + patch.object(binding_resolver, "resolve", wraps=WorkflowAgentBindingResolver().resolve), + patch("core.workflow.nodes.agent_v2.binding_resolver.session_factory.create_session") as create_session, + patch.object( + WorkflowAgentWorkspaceStore, + "load_active_participant", + side_effect=AssertionError("Workflow Tool must not select an outer Chatflow participant"), + ) as load_participant, + ): + create_session.return_value.__enter__.return_value = session + 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..e22e234f2a045d 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( [ @@ -541,3 +682,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..4651bd97be925c 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 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,50 @@ 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() -> None: + from core.tools.workflow_as_tool.tool import WorkflowTool + + tool = MagicMock(spec=WorkflowTool) + tool.workflow_app_id = uuid4() + tool.workflow_id = uuid4() + tool.version = "published-version" + tool.prepare_container_inputs.return_value = ( + {"amount": Decimal("1.25")}, + [{"id": "file-id", "name": "input.txt"}], + ) + 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, + } + tool.prepare_container_inputs.assert_called_once_with({"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..f584d352638563 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,30 @@ 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( + tenant_id="tenant", + app_id="outer-app", + workflow_id="outer-workflow", + graph_config=tool.init_params.graph_config, + graph=_outer_graph(tool), + user_id="user", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + call_depth=0, + variable_pool=tool.runtime_state.variable_pool, + graph_runtime_state=tool.runtime_state, + 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..019cfe3999633a --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py @@ -0,0 +1,1046 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from functools import partial +from types import SimpleNamespace +from typing import cast +from unittest.mock import MagicMock + +import pytest + +from core.helper.code_executor.code_executor import CodeExecutionError +from core.repositories.human_input_repository import ( + FormCreateParams, + HumanInputFormEntity, + HumanInputFormRepository, +) +from core.tools.workflow_as_tool.repository import WorkflowToolSource +from core.workflow.node_factory import DifyNodeFactory +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.nodes.human_input.enums import HumanInputFormStatus +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.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.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.model_runtime.entities.llm_entities import LLMUsage +from graphon.node_events import NodeRunResult, 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.protocols import ToolFileManagerProtocol +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 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, MagicMock, 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={"question": "hello"}, + call_depth=3, + ) + runtime = MagicMock() + runtime.get_runtime.return_value = ToolRuntimeHandle(raw=object()) + runtime.get_runtime_parameters.return_value = list[object]() + runtime.build_workflow_tool_container_payload.return_value = payload + 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=MagicMock(spec=ToolFileManagerProtocol), + 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() -> None: + node, runtime, payload = _workflow_tool_node() + + initial_events = list(node._run()) + + assert initial_events == [CustomContainerRequest(payload=payload.model_dump_json())] + runtime.get_runtime.assert_called_once_with( + node_id="tool", + node_data=node.node_data, + variable_pool=None, + node_execution_id="tool-execution", + ) + runtime.build_workflow_tool_container_payload.assert_called_once_with( + tool_runtime=runtime.get_runtime.return_value, + 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), + }, + ), + ) + + resumed_events = list(node._resume_container_events(result=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 completed.node_run_result.outputs == {"text": "done", "answer": 42} + 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() -> None: + node, runtime, _ = _workflow_tool_node(version="2") + 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" + runtime.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() -> None: + node, runtime, _ = _workflow_tool_node(tool_node_version="1") + 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 = object.__new__(DifyNodeFactory) + factory._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 = SimpleNamespace( + node_type=BuiltinNodeTypes.TOOL, + workflow_id="outer-workflow", + run_context=build_test_run_context( + tenant_id="tenant", + app_id="outer-app", + ), + ) + parent_graph = SimpleNamespace(nodes={"tool": parent_node}, node_factory=None) + frame_registry = FrameRegistry() + frame_registry.register( + ExecutionFrame( + frame_id=ROOT_FRAME_ID, + graph=cast(Graph, parent_graph), + state=parent_state, + scheduler=MagicMock(), + failure_handler=MagicMock(), + ) + ) + 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 = MagicMock(spec=EventStream) + 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 + 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"}], + } + assert not any(isinstance(call.args[0], NodeEvent) for call in event_stream.collect.call_args_list) + 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 = MagicMock() + built_in_handler.node_type = BuiltinNodeTypes.LOOP + built_in_handler.should_emit.return_value = True + hidden_event_listener = MagicMock() + nested_handler = WorkflowToolNestedContainerHandler( + frame_registry, + handler_factory=lambda _: built_in_handler, + hidden_event_listener=hidden_event_listener, + 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 + hidden_event_listener.assert_called_once_with(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: + execute = MagicMock(side_effect=[CodeExecutionError("source code failed"), {"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 execute.call_count == (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, + outcome: str, +) -> None: + run_start = StartNode._run + + def run_start_with_usage(node: StartNode) -> NodeRunResult: + result = run_start(node) + if node.id == "source-start": + result.llm_usage = LLMUsage.from_metadata({"total_tokens": 100}) + return result + + monkeypatch.setattr(StartNode, "_run", run_start_with_usage) + 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 = MagicMock(spec=HumanInputFormRepository) + form_repository.get_form.return_value = None + form = MagicMock(spec=HumanInputFormEntity) + + def create_form(params: FormCreateParams) -> HumanInputFormEntity: + form.id = params.form_id + return form + + form_repository.create_form.side_effect = 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 = object.__new__(DifyNodeFactory) + initial_owner_factory._execution_driver = None + initial_owner_factory.workflow_tools = {} + initial_owner_factory._human_input_run_context = DifyNodeFactory._resolve_dify_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) + assert (initial_state.node_run_steps, initial_state.total_tokens) == (4, 100) + 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 + ) + form_repository.create_form.assert_called_once() + create_params = form_repository.create_form.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": + assert (initial_state.node_run_steps, initial_state.total_tokens) == (4, 100) + 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 = object.__new__(DifyNodeFactory) + restored_owner_factory._execution_driver = None + restored_owner_factory.workflow_tools = {} + restored_owner_factory._human_input_run_context = initial_owner_factory.human_input_run_context + restored_graph.node_factory = restored_owner_factory + form.status = HumanInputFormStatus.WAITING if outcome == "abort-on-resume" else HumanInputFormStatus.SUBMITTED + form.submitted = outcome != "abort-on-resume" + form.created_at = datetime.now(UTC).replace(tzinfo=None) + form.expiration_time = form.created_at + timedelta(hours=1) + form.selected_action_id = "approve" + form.submitted_data = dict[str, object]() + form.rendered_content = "Approve this run?" + form_repository.get_form.side_effect = lambda _node_id, *, form_id: form if form_id == form.id else 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) + assert (restored_state.node_run_steps, restored_state.total_tokens) == (5, 100) + return + assert isinstance(resumed_events[-1], GraphRunSucceededEvent) + assert (restored_state.node_run_steps, restored_state.total_tokens) == (6, 100) + 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"] == "approve" + assert json.loads(tool_succeeded.node_run_result.outputs["text"]) == {"decision": "approve"} + form_repository.create_form.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..e8051a757ef16f --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_event_delivery.py @@ -0,0 +1,424 @@ +from dataclasses import replace +from datetime import UTC, datetime +from functools import partial +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from clients.agent_backend import FakeAgentBackendRunClient +from core.app.entities.app_invoke_entities import 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.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.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"] + 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=MagicMock(spec=EventStream), + 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 event.node_run_result.outputs == {"counter": 2} + assert persisted[0].node_run_result.outputs == {"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_factory = MagicMock(return_value=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") + listener_factory.assert_called_once_with(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"] == [{}] + 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 = MagicMock() + if caller_save_fails: + 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.model_construct( + task_id="task", + app_config=SimpleNamespace(app_id="outer-app", tenant_id="tenant"), + inputs={}, + files=[], + user_id=user.id, + 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: + binding_resolver.resolve.assert_not_called() + 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..c98471c47db418 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_file_access.py @@ -0,0 +1,209 @@ +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 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"] + runtime.build_workflow_tool_container_payload.return_value = 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" + ) + accepted_files = tool_completed.node_run_result.outputs["files"] + 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..3b0969b417d51e 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,208 @@ 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 +from extensions.logstore.aliyun_logstore import AliyunLogStore 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): + """Exercise the real protocol adapter while executing its SQL against local records.""" + store = object.__new__(AliyunLogStore) + store._use_pg_protocol = request.param == "pg" + store._pg_client = MagicMock() + store.client = MagicMock() + 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 MagicMock(get_logs=lambda: [MagicMock(get_contents=lambda row=row: row) for row in execute(sql)]) + + store._pg_client.execute_sql.side_effect = execute + store.client.get_logs.side_effect = get_logs + with patch( + "extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.AliyunLogStore", + return_value=store, + ): + 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 +214,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..3d270137786444 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_workflow_tool_source_repository.py @@ -0,0 +1,361 @@ +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) + 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) == {} + + 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)) == {_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]) + 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]) + + +_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: + sources = service.load(tenant_id=_TENANT_ID, graph_config=graph(parent_id), allow_human_input=True) + assert set(sources) == {_PUBLISHED_WORKFLOW_ID, _LATEST_WORKFLOW_ID} + else: + with pytest.raises(ValueError, match="require Engine-managed container execution"): + 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 (), + ) + 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/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_workflow_run_agg.py b/api/tests/unit_tests/services/workflow/test_workflow_run_agg.py index bf13b96f284681..7a3d8fcc6eaaf8 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 @@ -602,3 +602,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="require Engine-managed container execution"): + 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/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, }} /> From 4aa3b24ac05b3f7fa993e71047cbc9ea5ff90194 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 24 Sep 2026 02:59:15 +0800 Subject: [PATCH 02/16] fix(workflow): use typed node persistence fields Preserve caller locators with direct model fields and narrow LogStore caller access to Dify node executions. Reuse existing truncation properties when retaining offloaded previews in SQL and Celery cleanup batches so persistence adds no dynamic attribute reads. --- ...lalchemy_workflow_node_execution_repository.py | 15 ++++++++++----- ...logstore_workflow_node_execution_repository.py | 8 ++++++-- api/tasks/workflow_node_execution_tasks.py | 12 +++++++----- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py index 671629cec6aed1..8f8ca5f20ab4a1 100644 --- a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py +++ b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py @@ -429,13 +429,18 @@ def save_many(self, executions: Sequence[WorkflowNodeExecution]) -> None: 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 - for field in ("triggered_from_workflow_id", "triggered_from_node_execution_id"): - if getattr(db_model, field) is None: - setattr(db_model, field, getattr(existing, field)) + 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. - for offload in existing.offload_data: - setattr(db_model, offload.type_.value, getattr(existing, offload.type_.value)) + 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 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 42788c8b485b48..5e0c1182cd9714 100644 --- a/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py +++ b/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py @@ -191,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), @@ -198,8 +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", getattr(domain_model, "triggered_from_workflow_id", None) or ""), - ("triggered_from_node_execution_id", getattr(domain_model, "triggered_from_node_execution_id", None) or ""), + ("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), diff --git a/api/tasks/workflow_node_execution_tasks.py b/api/tasks/workflow_node_execution_tasks.py index e046da658111e0..c813715feeac30 100644 --- a/api/tasks/workflow_node_execution_tasks.py +++ b/api/tasks/workflow_node_execution_tasks.py @@ -81,12 +81,14 @@ def save_workflow_node_executions_task( ) ) continue - offloaded_values = { - offload.type_.value: getattr(existing, offload.type_.value) for offload in existing.offload_data - } + inputs, process_data, outputs = existing.inputs, existing.process_data, existing.outputs _update_node_execution_from_domain(existing, execution) - for field, value in offloaded_values.items(): - setattr(existing, field, value) + 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( From 620670d6dbe13f92179fefee0bf1c8fac75617f8 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 24 Sep 2026 10:12:46 +0800 Subject: [PATCH 03/16] test(workflow): accept Graphon 0.8.0 accounting limits Remove synthetic Start-node usage and exact nested aggregate expectations from the human-input lifecycle test. Preserve pause, resume, stop, form identity, and output behavior coverage without introducing host-side accounting. --- .../workflow/test_workflow_tool_container.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) 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 index 019cfe3999633a..02ad1d954611ef 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py @@ -57,8 +57,7 @@ WorkflowNodeExecutionStatus, ) from graphon.graph import Graph -from graphon.model_runtime.entities.llm_entities import LLMUsage -from graphon.node_events import NodeRunResult, StreamChunkEvent, StreamCompletedEvent +from graphon.node_events import StreamChunkEvent, StreamCompletedEvent from graphon.nodes.container_effects import ( ContainerExecutionResult, ContainerNodeRunResult, @@ -879,15 +878,6 @@ def test_workflow_tool_human_input_pauses_and_resumes_without_duplicate_form( monkeypatch: pytest.MonkeyPatch, outcome: str, ) -> None: - run_start = StartNode._run - - def run_start_with_usage(node: StartNode) -> NodeRunResult: - result = run_start(node) - if node.id == "source-start": - result.llm_usage = LLMUsage.from_metadata({"total_tokens": 100}) - return result - - monkeypatch.setattr(StartNode, "_run", run_start_with_usage) command_channel = InMemoryChannel() def stop_on_human_input(event: NodeEvent, *, channel: InMemoryChannel, should_stop: bool) -> None: @@ -957,7 +947,6 @@ def build_human_input_callback( paused = initial_events[-1] if outcome == "stopped": assert isinstance(paused, GraphRunAbortedEvent) - assert (initial_state.node_run_steps, initial_state.total_tokens) == (4, 100) return assert isinstance(paused, GraphRunPausedEvent) assert len(paused.reasons) == 1 @@ -980,7 +969,6 @@ def build_human_input_callback( assert human_input_app_ids == ["outer-app"] if outcome == "paused": - assert (initial_state.node_run_steps, initial_state.total_tokens) == (4, 100) return restored_state = RuntimeState.from_snapshot(initial_state.dumps()) @@ -1027,10 +1015,8 @@ def build_human_input_callback( if outcome == "abort-on-resume": assert isinstance(resumed_events[-1], GraphRunAbortedEvent) - assert (restored_state.node_run_steps, restored_state.total_tokens) == (5, 100) return assert isinstance(resumed_events[-1], GraphRunSucceededEvent) - assert (restored_state.node_run_steps, restored_state.total_tokens) == (6, 100) tool_succeeded = next( event for event in resumed_events if isinstance(event, NodeRunSucceededEvent) and event.node_id == "tool" ) From 8584589d7bf6d93969bcf6472df5648b4f681f59 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Wed, 30 Sep 2026 20:59:56 +0800 Subject: [PATCH 04/16] Test workflow tool binding with persisted SQLite fixtures --- .../nodes/agent_v2/test_agent_node.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) 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 7522b3a22946bc..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, AgentWorkspaceOwnerType, WorkflowAgentNodeBinding +from models.agent import ( + Agent, + AgentConfigSnapshot, + AgentScope, + AgentSource, + AgentWorkspaceOwnerType, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) from models.agent_config_entities import ( AgentSoulConfig, AgentSoulModelConfig, @@ -598,27 +607,28 @@ 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() -> None: +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", ) - session = MagicMock() - session.scalar.side_effect = [binding_resolver.binding, binding_resolver.agent, binding_resolver.snapshot] - with ( patch.object(binding_resolver, "resolve", wraps=WorkflowAgentBindingResolver().resolve), - patch("core.workflow.nodes.agent_v2.binding_resolver.session_factory.create_session") as create_session, patch.object( WorkflowAgentWorkspaceStore, "load_active_participant", side_effect=AssertionError("Workflow Tool must not select an outer Chatflow participant"), ) as load_participant, ): - create_session.return_value.__enter__.return_value = session events = list(node._run()) assert cast(StreamCompletedEvent, events[0]).node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED From 20b4821a3d37f3f27b7adcd2c76a2636b01a5e13 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Mon, 5 Oct 2026 20:33:08 +0800 Subject: [PATCH 05/16] test(workflow): use concrete workflow tool fixtures Replace spec mocks with real Graphon event streams, persisted human-input repositories, account sessions, and workflow tool instances. Preserve event delivery, retry history, payload, and pause/resume assertions under the upstream mock policy. --- .../app/workflow/test_persistence_layer.py | 14 ++++- .../core/tools/workflow_as_tool/test_tool.py | 5 +- .../core/workflow/test_node_runtime.py | 39 ++++++++++--- .../workflow/test_workflow_tool_container.py | 55 +++++++++---------- .../test_workflow_tool_event_delivery.py | 2 +- 5 files changed, 72 insertions(+), 43 deletions(-) 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 b39c3c3c157a80..23471474210a6f 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 @@ -27,6 +27,7 @@ from graphon.engine.frame import ExecutionFrame, FrameRegistry from graphon.engine.worker import NodeEventTask from graphon.engine_events import ( + EngineEvent, GraphRunAbortedEvent, GraphRunFailedEvent, GraphRunPartialSucceededEvent, @@ -391,6 +392,7 @@ def test_root_resume_isolates_same_app_workflow_tool_origin(sqlite_session_facto def test_workflow_tool_retry_starts_preserve_original_execution_and_attempt_history( sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, ) -> 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") @@ -408,8 +410,16 @@ def test_workflow_tool_retry_starts_preserve_original_execution_and_attempt_hist failure_handler=MagicMock(), ) ) - event_stream = MagicMock(spec=EventStream) - event_stream.collect.side_effect = listen + + 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: 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 df01a997adbf61..305099def1cc49 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 @@ -286,9 +286,10 @@ def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_fiel def test_workflow_tool_rejects_paused_generator_result( monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, ): tool = _build_tool() - user = MagicMock(spec=Account) + user = make_account(account_id="test_user") generate_mock = MagicMock( return_value={ "data": { @@ -304,7 +305,7 @@ def test_workflow_tool_rejects_paused_generator_result( monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) with pytest.raises(ToolInvokeError, match="requires Engine-managed container execution"): - list(tool.invoke(MagicMock(spec=Session), "test_user", {})) + list(tool.invoke(sqlite_session, "test_user", {})) generate_mock.assert_called_once() 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 4651bd97be925c..fb5701d1c382a5 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -892,17 +892,38 @@ 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() -> None: +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 = MagicMock(spec=WorkflowTool) - tool.workflow_app_id = uuid4() - tool.workflow_id = uuid4() - tool.version = "published-version" - tool.prepare_container_inputs.return_value = ( - {"amount": Decimal("1.25")}, - [{"id": "file-id", "name": "input.txt"}], + 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), + ) + prepare_container_inputs = MagicMock( + return_value=( + {"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( @@ -922,7 +943,7 @@ def test_dify_tool_node_runtime_builds_workflow_tool_container_payload() -> None "inputs_for_log": {"amount": 1.25}, "call_depth": 3, } - tool.prepare_container_inputs.assert_called_once_with({"amount": "1.25"}) + prepare_container_inputs.assert_called_once_with({"amount": "1.25"}) def test_dify_tool_node_runtime_rejects_non_workflow_tool_container_payload() -> None: 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 index 02ad1d954611ef..6977a77f6b477a 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py @@ -3,26 +3,26 @@ import json from collections.abc import Callable from dataclasses import replace -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from functools import partial from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.helper.code_executor.code_executor import CodeExecutionError from core.repositories.human_input_repository import ( - FormCreateParams, - HumanInputFormEntity, - HumanInputFormRepository, + HumanInputFormRepositoryImpl, + HumanInputFormSubmissionRepository, ) from core.tools.workflow_as_tool.repository import WorkflowToolSource from core.workflow.node_factory import DifyNodeFactory +from core.workflow.node_runtime import DifyToolFileManager 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.nodes.human_input.enums import HumanInputFormStatus from core.workflow.system_variables import SystemVariableKey, system_variable_selector from core.workflow.workflow_tool_container_handler import ( WorkflowToolContainerHandler, @@ -67,7 +67,6 @@ ) from graphon.nodes.end.end_node import EndNode from graphon.nodes.end.entities import EndNodeData -from graphon.nodes.protocols import ToolFileManagerProtocol from graphon.nodes.start.entities import StartNodeData from graphon.nodes.start.start_node import StartNode from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType @@ -137,7 +136,7 @@ def _workflow_tool_node( data=ToolNodeData.model_validate(graph_config["nodes"][0]["data"]), init_params=init_params, runtime_state=runtime_state, - tool_file_manager=MagicMock(spec=ToolFileManagerProtocol), + tool_file_manager=DifyToolFileManager(init_params.run_context), runtime=runtime, ) node.bind_execution_id("tool-execution") @@ -507,7 +506,7 @@ def test_workflow_tool_handler_runs_child_graph_with_internal_name_collision() - ("__workflow_tool_container__", "failure"), {"legitimate": True}, ) - event_stream = MagicMock(spec=EventStream) + event_stream = EventStream(layers=[]) processor = NodeEventProcessor( graph_execution=runtime_state.graph_execution, event_stream=event_stream, @@ -539,7 +538,8 @@ def test_workflow_tool_handler_runs_child_graph_with_internal_name_collision() - "files": [], "json": [{"answer": "ok"}], } - assert not any(isinstance(call.args[0], NodeEvent) for call in event_stream.collect.call_args_list) + 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): @@ -876,6 +876,7 @@ def test_workflow_tool_failure_accounting_uses_outer_tool_policy( @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() @@ -890,15 +891,10 @@ def stop_on_human_input(event: NodeEvent, *, channel: InMemoryChannel, should_st sources=source_repository, hidden_event_listener=partial(stop_on_human_input, channel=command_channel, should_stop=outcome == "stopped"), ) - form_repository = MagicMock(spec=HumanInputFormRepository) - form_repository.get_form.return_value = None - form = MagicMock(spec=HumanInputFormEntity) - - def create_form(params: FormCreateParams) -> HumanInputFormEntity: - form.id = params.form_id - return form - - form_repository.create_form.side_effect = create_form + 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( @@ -958,8 +954,8 @@ def build_human_input_callback( not isinstance(event, NodeEvent) or event.node_id not in {"source-start", "source-human"} for event in initial_events ) - form_repository.create_form.assert_called_once() - create_params = form_repository.create_form.call_args.args[0] + 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()) @@ -987,14 +983,15 @@ def build_human_input_callback( restored_owner_factory.workflow_tools = {} restored_owner_factory._human_input_run_context = initial_owner_factory.human_input_run_context restored_graph.node_factory = restored_owner_factory - form.status = HumanInputFormStatus.WAITING if outcome == "abort-on-resume" else HumanInputFormStatus.SUBMITTED - form.submitted = outcome != "abort-on-resume" - form.created_at = datetime.now(UTC).replace(tzinfo=None) - form.expiration_time = form.created_at + timedelta(hours=1) - form.selected_action_id = "approve" - form.submitted_data = dict[str, object]() - form.rendered_content = "Approve this run?" - form_repository.get_form.side_effect = lambda _node_id, *, form_id: form if form_id == form.id else None + 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, @@ -1022,7 +1019,7 @@ def build_human_input_callback( ) assert tool_succeeded.node_run_result.outputs["decision"] == "approve" assert json.loads(tool_succeeded.node_run_result.outputs["text"]) == {"decision": "approve"} - form_repository.create_form.assert_called_once() + 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"} 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 index e8051a757ef16f..b5e4dc79336399 100644 --- 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 @@ -200,7 +200,7 @@ def test_workflow_tool_persists_loop_outputs_after_graphon_normalizes_them() -> frame.state.variable_pool.add(("loop", "counter"), 2) processor = NodeEventProcessor( graph_execution=state.graph_execution, - event_stream=MagicMock(spec=EventStream), + event_stream=EventStream(layers=[]), frame_registry=frames, container_handlers={BuiltinNodeTypes.TOOL: handler}, ) From 3ec15af79b6d480ff3e6df81d27e291e04b52bbf Mon Sep 17 00:00:00 2001 From: -LAN- Date: Mon, 5 Oct 2026 23:04:15 +0800 Subject: [PATCH 06/16] test(workflow): record paused tool generator calls Use the existing call recorder for paused workflow tool results so the execution layer inherits the foundation mock-import cleanup while preserving the expected error and single generator invocation. --- api/core/workflow/workflow_tool_node.py | 4 ++-- .../unit_tests/core/tools/workflow_as_tool/test_tool.py | 6 +++--- .../graph_engine/layers/test_workflow_tool_observability.py | 6 ++---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/api/core/workflow/workflow_tool_node.py b/api/core/workflow/workflow_tool_node.py index 27045b32cef3b9..35c1a60678dd97 100644 --- a/api/core/workflow/workflow_tool_node.py +++ b/api/core/workflow/workflow_tool_node.py @@ -38,7 +38,7 @@ def version(cls) -> str: # subclasses use the broader Node contract. def _run( # type: ignore[override] # pyrefly: ignore[bad-override] self, - ) -> Generator[NodeEventPayload | ContainerAwaitRequest, None, None]: + ) -> Generator[NodeEventPayload | ContainerAwaitRequest]: tool_info = self._tool_info() try: variable_pool = None @@ -93,7 +93,7 @@ def _resume_container_events( self, *, result: ContainerRunResult, - ) -> Generator[NodeEventPayload | ContainerAwaitRequest, None, None]: + ) -> Generator[NodeEventPayload | ContainerAwaitRequest]: if not isinstance(result, ContainerExecutionResult): raise TypeError(f"Unsupported Workflow Tool container result {type(result).__name__}") 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 305099def1cc49..b280bc67e63e8c 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 @@ -290,7 +290,7 @@ def test_workflow_tool_rejects_paused_generator_result( ): tool = _build_tool() user = make_account(account_id="test_user") - generate_mock = MagicMock( + generate, generate_calls = _record_calls( return_value={ "data": { "status": WorkflowExecutionStatus.PAUSED, @@ -302,12 +302,12 @@ def test_workflow_tool_rejects_paused_generator_result( monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: _workflow_stub()) monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: user) - monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + 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", {})) - generate_mock.assert_called_once() + assert len(generate_calls) == 1 def test_legacy_tool_disables_human_input( 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 index 17f36c7317117a..8e2270a99c102d 100644 --- 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 @@ -91,9 +91,7 @@ def __exit__( tool, _, _ = _workflow_tool_node(state) original_resume = tool._resume_container_events - def instrument_resume( - *, result: ContainerRunResult - ) -> Generator[NodeEventPayload | ContainerAwaitRequest, None, None]: + def instrument_resume(*, result: ContainerRunResult) -> Generator[NodeEventPayload | ContainerAwaitRequest]: with tracer.start_as_current_span("resume-operation"): yield from original_resume(result=result) @@ -170,7 +168,7 @@ def test_interleaved_snippets_restore_caller_flask_context( tool, _, _ = _workflow_tool_node() node = _outer_graph(tool).root_node - def run_node() -> Generator[MagicMock, None, None]: + def run_node() -> Generator[MagicMock]: observed.append((current_app.name, g._login_user, request_id.get())) yield MagicMock() observed.append((current_app.name, g._login_user, request_id.get())) From 30bfdd879435ed2ebc208d9ffc3c6b40e15ddb7b Mon Sep 17 00:00:00 2001 From: -LAN- Date: Mon, 12 Oct 2026 11:20:25 +0800 Subject: [PATCH 07/16] refactor(workflow): remove unreachable workflow tool plumbing Tool persistence layers are created while the Engine runs, after node run indices are bound and graph start has fired, so the fan-out to existing tool layers never reached one. Drop it, inline the remaining helper, and rely on save_many accepting an empty batch. The child variable pool starts empty, so there is nothing to remove before the root inputs are added. Drop the never-passed WorkflowEntry workflow_tool_sources parameter, inline the single-use workspace key helper, and revert cosmetic rewrites of the Celery and LogStore query hunks. Adapt the new tests to the trimmed WorkflowEntry, _run_node_with_layers and WorkflowRunIndex signatures, and restore the mock factory hook that DifyNodeFactory now defines. --- ...elery_workflow_node_execution_repository.py | 8 ++------ api/core/tools/workflow_as_tool/tool.py | 6 +----- .../workflow/node_execution_process_data.py | 11 ----------- api/core/workflow/node_runtime.py | 6 +++--- .../workflow/nodes/agent_v2/session_store.py | 12 +++++------- api/core/workflow/workflow_entry.py | 4 +--- .../workflow_tool_container_handler.py | 1 - ...store_workflow_node_execution_repository.py | 10 ++++------ api/services/workflow_persistence.py | 11 ++--------- .../core/tools/workflow_as_tool/test_tool.py | 2 +- .../layers/test_workflow_tool_observability.py | 4 ++-- .../workflow/graph_engine/test_mock_factory.py | 18 ++++++++++++++++++ .../workflow/test_workflow_entry_helpers.py | 9 +-------- .../test_workflow_tool_event_delivery.py | 2 +- 14 files changed, 41 insertions(+), 63 deletions(-) diff --git a/api/core/repositories/celery_workflow_node_execution_repository.py b/api/core/repositories/celery_workflow_node_execution_repository.py index 0fc232a70ecc9c..ddf1250d0330b1 100644 --- a/api/core/repositories/celery_workflow_node_execution_repository.py +++ b/api/core/repositories/celery_workflow_node_execution_repository.py @@ -221,8 +221,6 @@ def get_by_workflow_execution( """ Retrieve workflow node executions from cache after loading persisted history once. - Resume hydration reads SQL directly when include_paused is requested. - Args: workflow_execution_id: The workflow execution identifier order_config: Optional configuration for ordering results @@ -231,11 +229,9 @@ def get_by_workflow_execution( A sequence of WorkflowNodeExecution instances """ if include_paused: - # Resume reads must not widen the ordinary runtime cache. + # Resume hydration must not widen the ordinary runtime cache. return self._sql_repository.get_by_workflow_execution( - workflow_execution_id, - order_config, - include_paused=include_paused, + workflow_execution_id, order_config, include_paused=True ) try: if workflow_execution_id not in self._database_loaded_workflow_executions: diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index 4fb8d4fa070ee5..dc46263692af91 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -175,11 +175,7 @@ def prepare_container_inputs( tool_parameters: Mapping[str, Any], ) -> tuple[dict[str, Any], list[dict[str, str | None]]]: """Merge configured parameters and convert them into workflow inputs.""" - runtime_parameters = self.runtime.runtime_parameters if self.runtime else None - merged_parameters = { - **tool_parameters, - **(runtime_parameters or {}), - } + 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) diff --git a/api/core/workflow/node_execution_process_data.py b/api/core/workflow/node_execution_process_data.py index a6d38369cf664a..1d5fbf49d28281 100644 --- a/api/core/workflow/node_execution_process_data.py +++ b/api/core/workflow/node_execution_process_data.py @@ -29,20 +29,9 @@ def keep_agent_and_tool_ids( return None if process_data is None and not updated_process_data else updated_process_data -def build_workflow_agent_workspace_key( - node_id: str, workflow_agent_binding_id: str, workflow_tool_invocation_id: str | None = None -) -> str: - """Partition repeated Tool calls without splitting loop executions inside a call.""" - workspace_key = f"{node_id}:{workflow_agent_binding_id}" - if workflow_tool_invocation_id is not None: - return f"workflow-tool:{workflow_tool_invocation_id}:{workspace_key}" - return workspace_key - - __all__ = [ "WORKFLOW_AGENT_BINDING_ID_KEY", "WORKFLOW_TOOL_INVOCATION_ID_KEY", "WORKFLOW_TOOL_ROOT_APP_ID_KEY", - "build_workflow_agent_workspace_key", "keep_agent_and_tool_ids", ] diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index 79684f61ed6227..334acaa7a3614c 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -706,9 +706,9 @@ def build_workflow_tool_container_payload( } ) return WorkflowToolContainerPayload( - source_app_id=str(tool.workflow_app_id), - source_workflow_id=str(tool.workflow_id), - source_workflow_version=str(tool.version), + 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"], diff --git a/api/core/workflow/nodes/agent_v2/session_store.py b/api/core/workflow/nodes/agent_v2/session_store.py index 755fbd9b938f9d..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,7 @@ 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, - build_workflow_agent_workspace_key, -) +from core.workflow.node_execution_process_data import WORKFLOW_TOOL_INVOCATION_ID_KEY from models.agent import ( AgentConfigVersionKind, AgentWorkspaceBinding, @@ -49,9 +46,10 @@ def resolve_workflow_agent_workspace_owner_scope( their invocation scope and the owning run's lifetime, even inside Chatflows. """ - owner_scope_key = build_workflow_agent_workspace_key( - node_id, workflow_agent_binding_id, workflow_tool_invocation_id - ) + owner_scope_key = f"{node_id}:{workflow_agent_binding_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, diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index c27537bd1fab88..84dc399dc85836 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -123,7 +123,6 @@ def __init__( graph: Graph, call_depth: int, graph_runtime_state: RuntimeState, - workflow_tool_sources: Mapping[str, WorkflowToolSource] | None = None, command_channel: CommandChannel | None = None, response_stream_filter: ResponseStreamFilter | None = None, ) -> None: @@ -133,7 +132,6 @@ def __init__( :param graph: workflow graph :param call_depth: call depth :param graph_runtime_state: pre-created graph runtime state - :param workflow_tool_sources: materialized pinned Workflow Tool definitions :param command_channel: command channel for external control (optional, defaults to InMemoryChannel) :param response_stream_filter: pre-restored filter for resumed runs (optional, defaults to a fresh ResponseStreamFilter for runs with no prior pause) @@ -149,7 +147,7 @@ def __init__( self.command_channel = command_channel self.graph_config = graph_config - self.workflow_tool_sources = dict(workflow_tool_sources or {}) + 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() diff --git a/api/core/workflow/workflow_tool_container_handler.py b/api/core/workflow/workflow_tool_container_handler.py index 544b88523222b9..4c2e113b5f8ea1 100644 --- a/api/core/workflow/workflow_tool_container_handler.py +++ b/api/core/workflow/workflow_tool_container_handler.py @@ -458,7 +458,6 @@ def _build_variable_pool( tenant_id=run_context.tenant_id, strict_type_validation=run_context.invoke_from == InvokeFrom.SERVICE_API, ) - variable_pool.remove((root_node_id,)) add_node_inputs_to_pool( variable_pool, node_id=root_node_id, 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 5e0c1182cd9714..714cc694a750c2 100644 --- a/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py +++ b/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py @@ -389,10 +389,9 @@ def get_by_workflow_execution( ) -> Sequence[WorkflowNodeExecution]: """ Retrieve all node executions for a workflow execution. + Paused nodes are always included; include_paused accepts the shared resume-read contract. Uses LogStore SQL query with window function to get the latest version of each node execution. This ensures we only get the most recent version of each node execution record. - LogStore already includes paused nodes by default; include_paused is - accepted for the shared resume-read contract without changing that behavior. Args: workflow_execution_id: The workflow execution identifier order_config: Optional configuration for ordering results @@ -442,12 +441,10 @@ def get_by_workflow_execution( # Build app_id filter for subquery app_id_filter = "" - escaped_app_id = escape_identifier(self._app_id or "") if self._app_id: + escaped_app_id = escape_identifier(self._app_id) app_id_filter = f" AND app_id='{escaped_app_id}'" - owner_filter = f"{origin_filter}{app_id_filter}" - # Use window function to get latest version of each node execution sql = f""" SELECT * FROM ( @@ -455,7 +452,8 @@ 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 {owner_filter} + AND {origin_filter} + {app_id_filter} ) t WHERE rn = 1 """ diff --git a/api/services/workflow_persistence.py b/api/services/workflow_persistence.py index 3903498fa26ec5..9244679933f004 100644 --- a/api/services/workflow_persistence.py +++ b/api/services/workflow_persistence.py @@ -97,8 +97,6 @@ def workflow_execution(self) -> WorkflowExecution: def set_node_run_indices(self, indices: Mapping[str, int]) -> None: """Consume the upper layer's indices, allocated before engine event delivery.""" self._node_run_indices = indices - for layer in self._workflow_tool_layers.values(): - layer.set_node_run_indices(indices) def set_node_execution_history(self, executions: Sequence[WorkflowNodeExecution]) -> None: """Restore materialized history before execution starts or a source frame is scheduled.""" @@ -136,7 +134,7 @@ def create_workflow_tool_event_listener( self._workflow_tool_layers[key] = tool_layer def on_node_event(event: NodeEvent) -> None: - self._prepare_workflow_tool_layer(tool_layer) + 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, @@ -153,9 +151,6 @@ def on_node_event(event: NodeEvent) -> None: return on_node_event - def _prepare_workflow_tool_layer(self, tool_layer: "WorkflowPersistenceLayer") -> None: - tool_layer._workflow_execution = self._get_workflow_execution() - # ------------------------------------------------------------------ # Layer lifecycle # ------------------------------------------------------------------ @@ -500,7 +495,6 @@ def persist_unfinished_nodes_synchronously(self) -> None: def _fail_running_node_executions(self, *, error_message: str) -> None: for tool_layer in self._workflow_tool_layers.values(): - self._prepare_workflow_tool_layer(tool_layer) tool_layer._fail_running_node_executions(error_message=error_message) now = naive_utc_now() unfinished = [] @@ -511,8 +505,7 @@ def _fail_running_node_executions(self, *, error_message: str) -> None: execution.finished_at = now execution.elapsed_time = max((now - execution.created_at).total_seconds(), 0.0) unfinished.append(execution) - if unfinished: - self._workflow_node_execution_repository.save_many(unfinished) + 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/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 b280bc67e63e8c..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 @@ -300,7 +300,7 @@ def test_workflow_tool_rejects_paused_generator_result( ) monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) - monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: _workflow_stub()) + 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) 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 index 8e2270a99c102d..3adf4bfcb9432b 100644 --- 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 @@ -136,7 +136,7 @@ def test_standalone_node_uses_worker_scoped_tracing_context( 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, tenant_id="tenant")) + events = list(WorkflowEntry._run_node_with_layers(node)) assert get_current_span() is parent assert isinstance(events[-1], NodeEvent) @@ -175,7 +175,7 @@ def run_node() -> Generator[MagicMock]: yield MagicMock() monkeypatch.setattr(node, "run", run_node) - snippets.append(WorkflowEntry._run_node_with_layers(node, tenant_id="tenant")) + snippets.append(WorkflowEntry._run_node_with_layers(node)) caller = DifyApp("caller") init_app(caller) 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/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py index f584d352638563..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 @@ -80,19 +80,12 @@ def test_hidden_workflow_tool_nodes_count_toward_execution_limit(self): source = _workflow_tool_source() with config_overrides_context(WORKFLOW_MAX_EXECUTION_STEPS=2, GRAPH_ENGINE_MAX_WORKERS=1): entry = workflow_entry.WorkflowEntry( - tenant_id="tenant", - app_id="outer-app", - workflow_id="outer-workflow", graph_config=tool.init_params.graph_config, graph=_outer_graph(tool), - user_id="user", - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, call_depth=0, - variable_pool=tool.runtime_state.variable_pool, graph_runtime_state=tool.runtime_state, - workflow_tool_sources={source.workflow_id: source}, ) + entry.workflow_tool_sources[source.workflow_id] = source events = list(entry.run()) assert isinstance(events[-1], GraphRunAbortedEvent) 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 index b5e4dc79336399..7c9ea7da8eb738 100644 --- 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 @@ -369,7 +369,7 @@ def fail_caller_save( triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, ), ) - index = WorkflowRunIndex() + 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") From e36170b500b0f2e48fb43f9e137784e1530db1d3 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Tue, 13 Oct 2026 07:44:23 +0800 Subject: [PATCH 08/16] fix(workflow): preserve tool definitions across durable pauses --- api/services/workflow_run_agg.py | 52 +++-- api/services/workflow_tool_source_service.py | 81 +++++++- .../test_workflow_tool_source_repository.py | 172 ++++++++++++++-- .../workflow/test_workflow_run_agg.py | 193 ++++++++++++++++++ 4 files changed, 455 insertions(+), 43 deletions(-) diff --git a/api/services/workflow_run_agg.py b/api/services/workflow_run_agg.py index ca9e2019b39b60..265337f1c34a0d 100644 --- a/api/services/workflow_run_agg.py +++ b/api/services/workflow_run_agg.py @@ -59,10 +59,12 @@ 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 WorkflowToolSourceService +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.""" @@ -87,29 +89,37 @@ 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) - self._entry.workflow_tool_sources.update( - WorkflowToolSourceService(SQLAlchemyWorkflowToolSourceRepository(session_factory.get_session_maker())).load( - tenant_id=self._run_context.tenant_id, - graph_config=self._entry.graph_config, - allow_human_input=( - 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) - ], - ) + 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=( + 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( - (source.tool.workflow_as_tool_id, source.tool) - for source in self._entry.workflow_tool_sources.values() - if source.tool is not None - ) + 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 diff --git a/api/services/workflow_tool_source_service.py b/api/services/workflow_tool_source_service.py index 6ac95955e3de89..ec995fd4ea0652 100644 --- a/api/services/workflow_tool_source_service.py +++ b/api/services/workflow_tool_source_service.py @@ -1,13 +1,58 @@ """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 @@ -19,8 +64,11 @@ def load( graph_config: Mapping[str, Any], allow_human_input: bool = True, suspended_tools: Sequence[WorkflowToolContainerPayload] = (), - ) -> dict[str, WorkflowToolSource]: + 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( @@ -34,6 +82,29 @@ def load( 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() @@ -46,12 +117,16 @@ def load( provider_id = data.get("provider_id") if isinstance(provider_id, str) and provider_id not in visited: provider_ids.add(provider_id) - if not provider_ids: + # 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 sources + return MaterializedWorkflowTools(sources=sources, tools=tools) 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 index 3d270137786444..3d7e450e55c554 100644 --- a/api/tests/unit_tests/repositories/test_workflow_tool_source_repository.py +++ b/api/tests/unit_tests/repositories/test_workflow_tool_source_repository.py @@ -35,17 +35,17 @@ def test_materialized_tool_sources_keep_the_published_version_and_tenant( 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 = 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) == {} + 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)) == {_LATEST_WORKFLOW_ID} + assert set(service.load(tenant_id=_TENANT_ID, graph_config=graph).sources) == {_LATEST_WORKFLOW_ID} suspended = WorkflowToolContainerPayload( source_app_id=_APP_ID, @@ -53,13 +53,136 @@ def test_materialized_tool_sources_keep_the_published_version_and_tenant( source_workflow_version=_PUBLISHED_VERSION, call_depth=1, ) - resumed = service.load(tenant_id=_TENANT_ID, graph_config=graph, suspended_tools=[suspended]) + 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" @@ -249,8 +372,15 @@ def graph(provider_id: str) -> dict[str, list[dict[str, object]]]: child.graph = json.dumps({"nodes": [*graph(parent_id)["nodes"], {"data": {"type": "human-input"}}]}) service = WorkflowToolSourceService(SQLAlchemyWorkflowToolSourceRepository(sqlite_session_factory)) if allow_human_input: - sources = service.load(tenant_id=_TENANT_ID, graph_config=graph(parent_id), allow_human_input=True) - assert set(sources) == {_PUBLISHED_WORKFLOW_ID, _LATEST_WORKFLOW_ID} + 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="require Engine-managed container execution"): + service.load( + tenant_id=_TENANT_ID, + graph_config=graph(parent_id), + allow_human_input=False, + snapshot=materialized.snapshot(), + ) else: with pytest.raises(ValueError, match="require Engine-managed container execution"): service.load(tenant_id=_TENANT_ID, graph_config=graph(parent_id), allow_human_input=False) @@ -316,19 +446,23 @@ def test_running_tool_keeps_materialized_definition_after_republication( ], "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 = ( + 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 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 7a3d8fcc6eaaf8..dfc591c621ed39 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,187 @@ 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")): + 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 From e41a207f438ea1a7a09028e1e8dc0e5d7a9aac93 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 15 Oct 2026 01:33:39 +0800 Subject: [PATCH 09/16] fix(workflow): remove obsolete tool publication error contracts Drop the upstream Human Input publication error declarations after nested workflow tools remove that restriction. Regenerate the console documentation and workspace contracts so the controller imports and advertised errors match supported behavior. --- api/controllers/console/workspace/tool_providers.py | 4 ---- api/openapi/markdown/console-openapi.md | 10 ---------- .../generated/api/console/workspaces/orpc.gen.ts | 13 ------------- .../generated/api/console/workspaces/types.gen.ts | 7 ------- .../generated/api/console/workspaces/zod.gen.ts | 12 ------------ 5 files changed, 46 deletions(-) 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/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/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 */ From 3f131de42335110b7c9b99b2580be46bcbeddd9a Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 15 Oct 2026 01:42:13 +0800 Subject: [PATCH 10/16] fix(workflow): clean nested tool offloads with their owning app Extend the upstream storage cleanup to include nested workflow-tool executions owned by the deleted caller app. Reuse the trusted ownership predicate for selection, shared-file checks, and reference deletion while preserving foreign and orphan references and retry safety. --- api/services/app_storage_cleanup_service.py | 29 ++-- .../test_app_storage_cleanup_service.py | 163 +++++++++++++++++- 2 files changed, 177 insertions(+), 15 deletions(-) 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/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() From 76df44f0b321cf3ecf1c5510937156a4532b934c Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 15 Oct 2026 02:40:00 +0800 Subject: [PATCH 11/16] test(workflow-tools): use concrete execution and storage fixtures Exercise real workflow-tool runtimes, container frames, bindings, and LogStore protocol adapters. Keep external calls isolated and adapt all shared fixture callers to the validated runtime objects. --- .../app/workflow/test_persistence_layer.py | 23 +++- .../test_workflow_node_execution_batch.py | 20 +-- .../test_workflow_node_execution_callers.py | 25 ++-- .../core/tools/test_tool_manager.py | 2 +- .../test_workflow_tool_observability.py | 24 +++- .../nodes/agent_v2/test_session_store.py | 11 +- .../core/workflow/test_node_runtime.py | 16 +-- .../workflow/test_workflow_tool_container.py | 121 ++++++++++-------- .../test_workflow_tool_event_delivery.py | 40 ++++-- .../test_workflow_tool_file_access.py | 2 +- ..._api_workflow_node_execution_repository.py | 29 +++-- 11 files changed, 193 insertions(+), 120 deletions(-) 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 23471474210a6f..bbcea51d0b8b68 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 @@ -3,7 +3,6 @@ from dataclasses import replace from datetime import UTC, datetime, timedelta from types import SimpleNamespace -from unittest.mock import MagicMock import pytest from sqlalchemy import event as sqlalchemy_event @@ -22,9 +21,11 @@ ) 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, @@ -46,20 +47,23 @@ from graphon.entities.pause_reason import SchedulingPause from graphon.enums import ( BuiltinNodeTypes, - NodeExecutionType, WorkflowExecutionStatus, WorkflowNodeExecutionMetadataKey, 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: @@ -399,15 +403,20 @@ def test_workflow_tool_retry_starts_preserve_original_execution_and_attempt_hist layer.on_event(GraphRunStartedEvent()) state = RuntimeState(workflow_id="source-workflow", variable_pool=VariablePool(), start_at=0) frames = FrameRegistry() - scheduler = MagicMock() - scheduler.process_node_success.return_value = ([], []) + node = StartNode( + node_id="child", + data=StartNodeData(title="Child", variables=[]), + init_params=build_test_graph_init_params(workflow_id="source-workflow"), + runtime_state=state, + ) + graph = Graph.new().add_root(node).build() frames.register( ExecutionFrame( frame_id=ROOT_FRAME_ID, - graph=MagicMock(nodes={"child": MagicMock(execution_type=NodeExecutionType.EXECUTABLE)}), + graph=graph, state=state, - scheduler=scheduler, - failure_handler=MagicMock(), + scheduler=Scheduler(graph, state, ROOT_FRAME_ID), + failure_handler=NodeFailureHandler(graph, state.graph_execution), ) ) 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 index 227f3802386651..5fcfc1d7b347fd 100644 --- 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 @@ -1,7 +1,6 @@ """Batch cleanup persists exact records without losing ownership or stored data.""" from datetime import datetime -from unittest.mock import Mock from uuid import uuid4 import pytest @@ -13,6 +12,7 @@ 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, ) @@ -78,7 +78,7 @@ def test_batch_creates_missing_rows_and_updates_only_supplied_records( execution.process_data = {"partial": "result"} queued: list[dict[str, object]] = [] - client = Mock() + 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)) @@ -91,9 +91,14 @@ def test_batch_creates_missing_rows_and_updates_only_supplied_records( ) 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( - "extensions.logstore.repositories.logstore_workflow_node_execution_repository.AliyunLogStore", - Mock(return_value=client, workflow_node_execution_logstore="workflow_node_execution"), + AliyunLogStore, + "put_log", + lambda _self, _name, record: logstore_records.append(dict(record)), ) repository = LogstoreWorkflowNodeExecutionRepository( expiring_session_factory, @@ -146,10 +151,9 @@ def test_batch_creates_missing_rows_and_updates_only_supplied_records( assert rows[existing.id].inputs_dict == {"large": "stored preview"} assert rows[existing.id].offload_data[0].file_id == "offloaded-inputs" if backend == "logstore": - records = [dict(call.args[1]) for call in client.put_log.call_args_list] - assert {row["id"] for row in records} == {existing.id, missing.id} - assert all(row["status"] == "failed" and row["log_version"] for row in records) - assert all(row["triggered_from_node_execution_id"] == "caller-execution" for row in records) + 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"]) 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 index 2a5bba37171963..4f51cce623cd7c 100644 --- 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 @@ -1,7 +1,6 @@ """Caller identity survives storage updates independently of node payloads.""" from datetime import datetime -from unittest.mock import Mock from uuid import uuid4 import pytest @@ -12,6 +11,7 @@ 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, ) @@ -29,11 +29,7 @@ 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) - client = Mock() - monkeypatch.setattr( - "extensions.logstore.repositories.logstore_workflow_node_execution_repository.AliyunLogStore", - Mock(return_value=client, workflow_node_execution_logstore="workflow_node_execution"), - ) + logstore_records: list[dict[str, str]] = [] sql = SQLAlchemyWorkflowNodeExecutionRepository( sqlite_session_factory, "tenant", make_account(), "source-app", WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL ) @@ -66,6 +62,15 @@ def deliver(**kwargs: object) -> None: 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", @@ -84,13 +89,9 @@ def deliver(**kwargs: object) -> None: repository.save_execution_data(execution) if backend == "logstore": - row = dict(client.put_log.call_args.args[1]) - client.execute_sql.return_value = [row] + row = logstore_records[-1] + monkeypatch.setattr(AliyunLogStore, "execute_sql", lambda *_args, **_kwargs: [row]) restored = repository.get_by_workflow_execution("root-run")[0] - monkeypatch.setattr( - "extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.AliyunLogStore", - Mock(return_value=client, workflow_node_execution_logstore="workflow_node_execution"), - ) apply_config_overrides( monkeypatch, API_WORKFLOW_NODE_EXECUTION_REPOSITORY=( 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/workflow/graph_engine/layers/test_workflow_tool_observability.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_workflow_tool_observability.py index 3adf4bfcb9432b..563b40824d0ba4 100644 --- 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 @@ -5,7 +5,6 @@ from threading import Event, get_ident from types import TracebackType from typing import override -from unittest.mock import MagicMock import pytest from flask import current_app, g @@ -23,8 +22,9 @@ from dify_app import DifyApp from graphon.engine import Engine from graphon.engine.layer import Layer -from graphon.engine_events import GraphRunSucceededEvent, NodeEvent +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 @@ -168,11 +168,25 @@ def test_interleaved_snippets_restore_caller_flask_context( tool, _, _ = _workflow_tool_node() node = _outer_graph(tool).root_node - def run_node() -> Generator[MagicMock]: + def run_node() -> Generator[NodeRunStreamChunkEvent]: observed.append((current_app.name, g._login_user, request_id.get())) - yield MagicMock() + 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 MagicMock() + 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)) 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 e22e234f2a045d..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 @@ -539,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", @@ -561,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: 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 fb5701d1c382a5..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,4 +1,4 @@ -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from datetime import UTC, datetime from decimal import Decimal from types import SimpleNamespace @@ -917,12 +917,12 @@ def test_dify_tool_node_runtime_builds_workflow_tool_container_payload(monkeypat ), runtime=ToolRuntime(tenant_id="tenant-id", invoke_from=InvokeFrom.DEBUGGER), ) - prepare_container_inputs = MagicMock( - return_value=( - {"amount": Decimal("1.25")}, - [{"id": "file-id", "name": "input.txt"}], - ) - ) + 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()) @@ -943,7 +943,7 @@ def test_dify_tool_node_runtime_builds_workflow_tool_container_payload(monkeypat "inputs_for_log": {"amount": 1.25}, "call_depth": 3, } - prepare_container_inputs.assert_called_once_with({"amount": "1.25"}) + assert prepared_parameters == [{"amount": "1.25"}] def test_dify_tool_node_runtime_rejects_non_workflow_tool_container_payload() -> None: 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 index 6977a77f6b477a..7ac33a17b0741c 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py @@ -5,21 +5,23 @@ from dataclasses import replace from datetime import UTC, datetime from functools import partial -from types import SimpleNamespace -from typing import cast -from unittest.mock import MagicMock 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 +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 @@ -33,10 +35,13 @@ 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 @@ -85,7 +90,7 @@ def _workflow_tool_node( app_id: str = "outer-app", version: str = "1", tool_node_version: str | None = None, -) -> tuple[DifyWorkflowToolNode, MagicMock, WorkflowToolContainerPayload]: +) -> tuple[DifyWorkflowToolNode, DifyToolNodeRuntime, WorkflowToolContainerPayload]: graph_config = { "nodes": [ { @@ -124,13 +129,31 @@ def _workflow_tool_node( source_workflow_id="source-workflow", source_workflow_version="1", inputs={"question": "hello"}, - inputs_for_log={"question": "hello"}, + inputs_for_log={}, call_depth=3, ) - runtime = MagicMock() - runtime.get_runtime.return_value = ToolRuntimeHandle(raw=object()) - runtime.get_runtime_parameters.return_value = list[object]() - runtime.build_workflow_tool_container_payload.return_value = payload + 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"]), @@ -153,20 +176,22 @@ def _outer_graph(node: DifyWorkflowToolNode) -> Graph: 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() -> None: +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())] - runtime.get_runtime.assert_called_once_with( + get_runtime.assert_called_once_with( node_id="tool", node_data=node.node_data, variable_pool=None, node_execution_id="tool-execution", ) - runtime.build_workflow_tool_container_payload.assert_called_once_with( - tool_runtime=runtime.get_runtime.return_value, + build_payload.assert_called_once_with( + tool_runtime=get_runtime.spy_return, tool_parameters={}, inputs_for_log={}, workflow_call_depth=2, @@ -203,9 +228,9 @@ def test_workflow_tool_node_requests_child_container_and_resumes_successfully() } -def test_workflow_tool_node_returns_failure_when_runtime_cannot_be_loaded() -> None: +def test_workflow_tool_node_returns_failure_when_runtime_cannot_be_loaded(mocker: MockerFixture) -> None: node, runtime, _ = _workflow_tool_node(version="2") - runtime.get_runtime.side_effect = ToolNodeError("runtime unavailable") + get_runtime = mocker.patch.object(runtime, "get_runtime", side_effect=ToolNodeError("runtime unavailable")) events = list(node._run()) @@ -214,7 +239,7 @@ def test_workflow_tool_node_returns_failure_when_runtime_cannot_be_loaded() -> N 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" - runtime.get_runtime.assert_called_once_with( + get_runtime.assert_called_once_with( node_id="tool", node_data=node.node_data, variable_pool=node.runtime_state.variable_pool, @@ -222,9 +247,11 @@ def test_workflow_tool_node_returns_failure_when_runtime_cannot_be_loaded() -> N ) -def test_workflow_tool_node_returns_failure_when_payload_cannot_be_built() -> None: +def test_workflow_tool_node_returns_failure_when_payload_cannot_be_built(mocker: MockerFixture) -> None: node, runtime, _ = _workflow_tool_node(tool_node_version="1") - runtime.build_workflow_tool_container_payload.side_effect = ToolNodeError("invalid source workflow") + mocker.patch.object( + runtime, "build_workflow_tool_container_payload", side_effect=ToolNodeError("invalid source workflow") + ) events = list(node._run()) @@ -288,8 +315,7 @@ def test_workflow_tool_node_resumes_empty_text_with_final_chunk_only() -> None: def test_node_factory_can_keep_workflow_tool_direct_for_single_step_debug() -> None: node, _, _ = _workflow_tool_node() - factory = object.__new__(DifyNodeFactory) - factory._use_workflow_tool_containers = True + factory = DifyNodeFactory(node.init_params, node.runtime_state, use_workflow_tool_containers=True) assert ( factory._resolve_node_class_for_factory( node_type=BuiltinNodeTypes.TOOL, @@ -429,23 +455,16 @@ def _container_handler( variable_pool=parent_pool, start_at=1, ) - parent_node = SimpleNamespace( - node_type=BuiltinNodeTypes.TOOL, - workflow_id="outer-workflow", - run_context=build_test_run_context( - tenant_id="tenant", - app_id="outer-app", - ), - ) - parent_graph = SimpleNamespace(nodes={"tool": parent_node}, node_factory=None) + 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=cast(Graph, parent_graph), + graph=parent_graph, state=parent_state, - scheduler=MagicMock(), - failure_handler=MagicMock(), + scheduler=Scheduler(parent_graph, parent_state, ROOT_FRAME_ID), + failure_handler=NodeFailureHandler(parent_graph, parent_state.graph_execution), ) ) source_repository = {} @@ -641,14 +660,12 @@ def test_workflow_tool_nested_handler_hides_and_persists_marked_child_events() - event_listeners=event_listeners, ) workflow_tool_handler.handle_request(invocation_id="invocation", request=request) - built_in_handler = MagicMock() - built_in_handler.node_type = BuiltinNodeTypes.LOOP - built_in_handler.should_emit.return_value = True - hidden_event_listener = MagicMock() + 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_event_listener, + hidden_event_listener=hidden_events.append, event_listeners=event_listeners, ) event = NodeRunSucceededEvent( @@ -665,7 +682,7 @@ def test_workflow_tool_nested_handler_hides_and_persists_marked_child_events() - ) assert nested_handler.should_emit(event=event) is False - hidden_event_listener.assert_called_once_with(event) + 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" @@ -769,7 +786,15 @@ def test_workflow_tool_failure_accounting_uses_outer_tool_policy( expected_output: str | None, retry_once: bool, ) -> None: - execute = MagicMock(side_effect=[CodeExecutionError("source code failed"), {"output": "retried output"}]) + 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) @@ -853,7 +878,7 @@ def test_workflow_tool_failure_accounting_uses_outer_tool_policy( else: events.extend(engine.run()) - assert execute.call_count == (2 if retry_once else 1) + 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): @@ -923,11 +948,8 @@ def build_human_input_callback( ) initial_node, _, _ = _workflow_tool_node(initial_state, app_id="intermediate-app") initial_graph = _outer_graph(initial_node) - initial_owner_factory = object.__new__(DifyNodeFactory) - initial_owner_factory._execution_driver = None - initial_owner_factory.workflow_tools = {} - initial_owner_factory._human_input_run_context = DifyNodeFactory._resolve_dify_context( - build_test_run_context(app_id="outer-app") + 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( @@ -978,10 +1000,9 @@ def build_human_input_callback( ) restored_node, _, _ = _workflow_tool_node(restored_state, app_id="intermediate-app") restored_graph = _outer_graph(restored_node) - restored_owner_factory = object.__new__(DifyNodeFactory) - restored_owner_factory._execution_driver = None - restored_owner_factory.workflow_tools = {} - restored_owner_factory._human_input_run_context = initial_owner_factory.human_input_run_context + 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( 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 index 7c9ea7da8eb738..cbd7f1e390ca00 100644 --- 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 @@ -1,15 +1,15 @@ +from collections.abc import Callable from dataclasses import replace from datetime import UTC, datetime from functools import partial -from types import SimpleNamespace -from unittest.mock import MagicMock import pytest from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from clients.agent_backend import FakeAgentBackendRunClient -from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity +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 @@ -17,6 +17,7 @@ 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, @@ -40,6 +41,7 @@ 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 @@ -247,7 +249,13 @@ def persist_event(event: NodeEvent) -> None: if listener_fails: raise RuntimeError("Trace store unavailable") - listener_factory = MagicMock(return_value=persist_event) + 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( @@ -267,7 +275,7 @@ def persist_event(event: NodeEvent) -> None: assert isinstance(events[-1], GraphRunSucceededEvent) caller_event = next(event for event in events if isinstance(event, NodeRunStartedEvent) and event.node_id == "tool") - listener_factory.assert_called_once_with(source, node.workflow_id, caller_event.id) + 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", @@ -293,8 +301,15 @@ def test_workflow_tool_agent_finds_its_persisted_caller_before_resolving_binding sqlite_session_factory: sessionmaker[Session], caller_save_fails: bool, ) -> None: - binding_resolver = MagicMock() + 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 @@ -339,12 +354,19 @@ def fail_caller_save( user = Account(name="Test", email="test@example.com") user.id = "user" layer = WorkflowPersistenceLayer( - application_generate_entity=WorkflowAppGenerateEntity.model_construct( + application_generate_entity=WorkflowAppGenerateEntity( task_id="task", - app_config=SimpleNamespace(app_id="outer-app", tenant_id="tenant"), + 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", ), @@ -411,7 +433,7 @@ def fail_caller_save( ) assert execution.status == "exception" if caller_save_fails: - binding_resolver.resolve.assert_not_called() + assert resolution_calls == [] assert execution.error == "Workflow node execution caller is unavailable" assert execution.outputs_dict["error_type"] == "agent_workflow_node_runtime_error" else: 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 index c98471c47db418..bad854036f127d 100644 --- 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 @@ -154,7 +154,7 @@ def test_workflow_tool_dispatcher_enforces_file_ownership( value = mapping if input_kind == "file" else [mapping] payload = payload.model_copy(update={"inputs": {"attachment": value}}) output_selector = ["source-start", "attachment"] - runtime.build_workflow_tool_container_payload.return_value = payload + monkeypatch.setattr(runtime, "build_workflow_tool_container_payload", lambda **_kwargs: payload) source = WorkflowToolSource( app_id="source-app", workflow_id="source-workflow", 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 3b0969b417d51e..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 @@ -6,9 +6,11 @@ from unittest.mock import MagicMock, patch import pytest -from aliyun.log import GetLogsRequest +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, @@ -17,12 +19,15 @@ @pytest.fixture(params=["pg", "sdk"]) -def sql_repository(request: pytest.FixtureRequest): +def sql_repository(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture): """Exercise the real protocol adapter while executing its SQL against local records.""" - store = object.__new__(AliyunLogStore) + 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 = MagicMock() - store.client = MagicMock() + store._pg_client = AliyunLogStorePG("key", "secret", "localhost", "test") store.project_name = "test" store.log_enabled = False with closing(sqlite3.connect(":memory:")) as database: @@ -37,15 +42,11 @@ def get_logs(log_request: GetLogsRequest): assert search assert log_request.get_from() == 0 assert log_request.get_to() <= int(time.time()) - return MagicMock(get_logs=lambda: [MagicMock(get_contents=lambda row=row: row) for row in execute(sql)]) - - store._pg_client.execute_sql.side_effect = execute - store.client.get_logs.side_effect = get_logs - with patch( - "extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.AliyunLogStore", - return_value=store, - ): - repository = LogstoreAPIWorkflowNodeExecutionRepository(session_maker=None) + 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 From 89e7e5a5f693084b06fd950e83251147ed2b04f4 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 15 Oct 2026 10:15:02 +0800 Subject: [PATCH 12/16] fix(workflow-tools): guard retrying nodes in batch persistence Apply durable cancellation to retry records in SQL and Celery batch writers. Exercise root and nested-source retries through actual engine event processing, preserving retry history, paused participants, and batched cleanup ownership. --- ...hemy_workflow_node_execution_repository.py | 6 +- api/tasks/workflow_node_execution_tasks.py | 6 +- .../app/workflow/test_persistence_layer.py | 113 ++++++++++++++---- 3 files changed, 99 insertions(+), 26 deletions(-) diff --git a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py index 8f8ca5f20ab4a1..dd0603b27bf756 100644 --- a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py +++ b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py @@ -399,7 +399,11 @@ def save_many(self, executions: Sequence[WorkflowNodeExecution]) -> None: 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): + if db_model.status in ( + WorkflowNodeExecutionStatus.RUNNING, + WorkflowNodeExecutionStatus.PAUSED, + WorkflowNodeExecutionStatus.RETRY, + ): apply_workflow_stop_to_node( db_model, get_stopped_workflow_run( diff --git a/api/tasks/workflow_node_execution_tasks.py b/api/tasks/workflow_node_execution_tasks.py index c813715feeac30..8c6f353c9acfcc 100644 --- a/api/tasks/workflow_node_execution_tasks.py +++ b/api/tasks/workflow_node_execution_tasks.py @@ -44,7 +44,11 @@ def save_workflow_node_executions_task( return True with session_factory.create_session() as session, session.begin(): for execution in executions: - if execution.status in (WorkflowNodeExecutionStatus.RUNNING, WorkflowNodeExecutionStatus.PAUSED): + if execution.status in ( + WorkflowNodeExecutionStatus.RUNNING, + WorkflowNodeExecutionStatus.PAUSED, + WorkflowNodeExecutionStatus.RETRY, + ): apply_workflow_stop_to_node( execution, get_stopped_workflow_run( 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 bbcea51d0b8b68..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 @@ -394,19 +394,28 @@ def test_root_resume_isolates_same_app_workflow_tool_origin(sqlite_session_facto assert rows["older-exec"].workflow_id == source_workflow_id -def test_workflow_tool_retry_starts_preserve_original_execution_and_attempt_history( +@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") + listen = ( + layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "caller-exec") + if source + else layer.on_event + ) layer.on_event(GraphRunStartedEvent()) - state = RuntimeState(workflow_id="source-workflow", variable_pool=VariablePool(), start_at=0) + 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="source-workflow"), + init_params=build_test_graph_init_params(workflow_id=workflow_id), runtime_state=state, ) graph = Graph.new().add_root(node).build() @@ -457,21 +466,49 @@ def dispatch(event: NodeEvent) -> None: ) ) dispatch(started) - 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}), + 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.status == "succeeded" - assert row.outputs_dict == {"ok": True} + 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"), @@ -509,7 +546,17 @@ def test_root_terminal_failure_finishes_running_workflow_tool_nodes(sqlite_sessi def test_terminal_failure_batches_only_unfinished_nodes_per_source( sqlite_session_factory: sessionmaker[Session], ) -> None: - ids = ("root-a", "root-b", "root-paused", "source-a", "source-b", "source-done") + 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()) @@ -525,14 +572,27 @@ def test_terminal_failure_batches_only_unfinished_nodes_per_source( start_at=started_at, ) ) - layer.on_event( - NodeRunPauseRequestedEvent( - id="root-paused", - node_id="root-paused", - node_type=BuiltinNodeTypes.CODE, - reason=SchedulingPause(message="pause"), - ) - ) + 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", @@ -558,15 +618,20 @@ def record_commit(_session: Session) -> None: 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", "source-a", "source-b"): + 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. From fe280d5e9f8cdb3c88862f42092a42106dbfad1d Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 15 Oct 2026 10:41:27 +0800 Subject: [PATCH 13/16] fix(workflow): preserve typed container outputs through resume Declare workflow-tool files as ArrayFileSegment and preserve the existing text stream with typed Graphon completion outputs. Pin only the API to the container-type patch on Graphon 0.8.0, and project runtime values before Celery payload serialization so history retains its public JSON shape. Cover snapshot restoration, parent variable types, async persistence, and existing file access behavior. --- ...lery_workflow_node_execution_repository.py | 18 ++++- .../workflow_tool_container_handler.py | 3 +- api/core/workflow/workflow_tool_node.py | 5 +- api/pyproject.toml | 2 + ...lery_workflow_node_execution_repository.py | 74 ++++++++++++++++++- .../workflow/test_workflow_tool_container.py | 35 ++++++++- .../test_workflow_tool_event_delivery.py | 8 +- .../test_workflow_tool_file_access.py | 5 +- api/uv.lock | 8 +- 9 files changed, 136 insertions(+), 22 deletions(-) diff --git a/api/core/repositories/celery_workflow_node_execution_repository.py b/api/core/repositories/celery_workflow_node_execution_repository.py index ddf1250d0330b1..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,6 +20,7 @@ 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 ( @@ -154,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( @@ -186,7 +187,7 @@ def save_many(self, executions: Sequence[WorkflowNodeExecution]) -> None: if execution.id not in execution_ids: execution_ids.append(execution.id) save_workflow_node_executions_task.delay( - executions_data=[execution.model_dump() for execution in executions], + 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 "", @@ -194,6 +195,17 @@ def save_many(self, executions: Sequence[WorkflowNodeExecution]) -> None: 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/workflow/workflow_tool_container_handler.py b/api/core/workflow/workflow_tool_container_handler.py index 4c2e113b5f8ea1..aecc24440f85a6 100644 --- a/api/core/workflow/workflow_tool_container_handler.py +++ b/api/core/workflow/workflow_tool_container_handler.py @@ -53,6 +53,7 @@ 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 @@ -475,7 +476,7 @@ def _build_tool_outputs(workflow_outputs: Mapping[str, object]) -> dict[str, Con 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": WorkflowToolContainerHandler._collect_files(workflow_outputs), + "files": ArrayFileSegment(value=WorkflowToolContainerHandler._collect_files(workflow_outputs)), "json": [json_outputs], } return {key: build_container_value(value) for key, value in tool_outputs.items()} diff --git a/api/core/workflow/workflow_tool_node.py b/api/core/workflow/workflow_tool_node.py index 35c1a60678dd97..f9a83997ec9bc4 100644 --- a/api/core/workflow/workflow_tool_node.py +++ b/api/core/workflow/workflow_tool_node.py @@ -18,6 +18,7 @@ ) 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 @@ -104,10 +105,10 @@ def _resume_container_events( ) if node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED: text_output = node_run_result.outputs.get("text") - if isinstance(text_output, str) and text_output: + if isinstance(text_output, StringSegment) and text_output.value: yield StreamChunkEvent( selector=[self._node_id, "text"], - chunk=text_output, + chunk=text_output.value, is_final=False, ) yield StreamChunkEvent( 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/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 a70669a597bfd2..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 @@ -18,9 +18,14 @@ 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" @@ -66,6 +71,73 @@ 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 ): 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 index 7ac33a17b0741c..b7ed47ce861719 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_container.py @@ -81,6 +81,8 @@ 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 @@ -206,11 +208,20 @@ def test_workflow_tool_node_requests_child_container_and_resumes_successfully(mo 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(node._resume_container_events(result=result)) + 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), @@ -219,7 +230,18 @@ def test_workflow_tool_node_requests_child_container_and_resumes_successfully(mo completed = resumed_events[2] assert isinstance(completed, StreamCompletedEvent) assert completed.node_run_result.inputs == {"question": "hello"} - assert completed.node_run_result.outputs == {"text": "done", "answer": 42} + 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, @@ -550,6 +572,7 @@ def test_workflow_tool_handler_runs_child_graph_with_internal_name_collision() - 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", @@ -1038,8 +1061,12 @@ def build_human_input_callback( 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"] == "approve" - assert json.loads(tool_succeeded.node_run_result.outputs["text"]) == {"decision": "approve"} + 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( 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 index cbd7f1e390ca00..137b69fe621387 100644 --- 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 @@ -163,7 +163,7 @@ def edge(source: str, target: str, handle: str = "source") -> dict[str, str]: assert isinstance(events[-1], GraphRunSucceededEvent) assert state.outputs == {"rounds": 3} assert [ - event.node_run_result.outputs["loop_round"] + 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] @@ -216,8 +216,8 @@ def test_workflow_tool_persists_loop_outputs_after_graphon_normalizes_them() -> processor.dispatch(NodeEventTask(frame_id=frame.frame_id, event=event)) - assert event.node_run_result.outputs == {"counter": 2} - assert persisted[0].node_run_result.outputs == {"counter": 2} + 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 == "" @@ -288,7 +288,7 @@ def listener_factory( 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"] == [{}] + 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: 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 index bad854036f127d..c380bf26683a09 100644 --- 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 @@ -17,6 +17,7 @@ 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 @@ -204,6 +205,8 @@ def test_workflow_tool_dispatcher_enforces_file_ownership( tool_completed = next( event for event in events if isinstance(event, NodeRunSucceededEvent) and event.node_id == "tool" ) - accepted_files = tool_completed.node_run_result.outputs["files"] + files_output = tool_completed.node_run_result.outputs["files"] + assert isinstance(files_output, ArrayFileSegment) + accepted_files = files_output.to_object() assert len(accepted_files) == 1 assert accepted_files[0].filename == stored_file.name 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" From 9b4f9e5b37eb409df1c0efa1eec13e53010396ec Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 15 Oct 2026 10:55:38 +0800 Subject: [PATCH 14/16] fix(ci): stabilize workflow stack checks Chain batch workflow persistence retries to their original error and load Microsoft.PowerShell.Utility before the installer fixture constructs HttpResponseException. Wait for the combobox popup to unmount before the Default story finishes, so accessibility checks observe the completed close transition. Retain the typed file-access assertion and equal-length published snapshot fixtures required by the strict checks. --- api/tasks/workflow_node_execution_tasks.py | 2 +- .../core/workflow/test_workflow_tool_file_access.py | 2 +- .../unit_tests/services/workflow/test_workflow_run_agg.py | 4 +++- packages/dify-ui/src/combobox/index.stories.tsx | 4 ++++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/api/tasks/workflow_node_execution_tasks.py b/api/tasks/workflow_node_execution_tasks.py index 8c6f353c9acfcc..f89629bbc72461 100644 --- a/api/tasks/workflow_node_execution_tasks.py +++ b/api/tasks/workflow_node_execution_tasks.py @@ -98,7 +98,7 @@ def save_workflow_node_executions_task( 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)) + 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) 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 index c380bf26683a09..9e175003c5c2fc 100644 --- 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 @@ -207,6 +207,6 @@ def test_workflow_tool_dispatcher_enforces_file_ownership( ) files_output = tool_completed.node_run_result.outputs["files"] assert isinstance(files_output, ArrayFileSegment) - accepted_files = files_output.to_object() + 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/services/workflow/test_workflow_run_agg.py b/api/tests/unit_tests/services/workflow/test_workflow_run_agg.py index dfc591c621ed39..d95c1f2df74c10 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 @@ -412,7 +412,9 @@ def test_pause_before_first_tool_call_preserves_its_published_definition(sqlite_ enable_api=False, ) ) - for workflow_id, version, marker in zip(source_workflow_ids, ("old", "new"), ("original", "republished")): + for workflow_id, version, marker in zip( + source_workflow_ids, ("old", "new"), ("original", "republished"), strict=True + ): session.add( Workflow( id=workflow_id, 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() + }) }, } From 81f21e5765a2649cae6f0fa8edcf484159c4bbfc Mon Sep 17 00:00:00 2001 From: WH-2099 Date: Tue, 20 Oct 2026 09:07:34 +0000 Subject: [PATCH 15/16] fix(workflow): enforce pipeline tool lifecycle --- api/services/workflow_run_agg.py | 44 ++-- api/services/workflow_tool_source_service.py | 2 +- .../test_workflow_tool_source_repository.py | 4 +- .../workflow/test_pipeline_workflow_tools.py | 222 ++++++++++++++++++ .../workflow/test_workflow_run_agg.py | 2 +- 5 files changed, 255 insertions(+), 19 deletions(-) create mode 100644 api/tests/unit_tests/services/workflow/test_pipeline_workflow_tools.py diff --git a/api/services/workflow_run_agg.py b/api/services/workflow_run_agg.py index 265337f1c34a0d..06e4ad491f262d 100644 --- a/api/services/workflow_run_agg.py +++ b/api/services/workflow_run_agg.py @@ -54,6 +54,7 @@ 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 @@ -101,9 +102,12 @@ def __init__( tenant_id=self._run_context.tenant_id, graph_config=self._entry.graph_config, allow_human_input=( - prepared.generate_entity.allow_human_input - if isinstance(prepared.generate_entity, WorkflowAppGenerateEntity) - else True + 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) @@ -146,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())) @@ -198,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_tool_source_service.py b/api/services/workflow_tool_source_service.py index ec995fd4ea0652..7229d98cc0ac67 100644 --- a/api/services/workflow_tool_source_service.py +++ b/api/services/workflow_tool_source_service.py @@ -112,7 +112,7 @@ def load( 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 Workflow Tools require Engine-managed container execution.") + 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: 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 index 3d7e450e55c554..b5181fae723ab4 100644 --- a/api/tests/unit_tests/repositories/test_workflow_tool_source_repository.py +++ b/api/tests/unit_tests/repositories/test_workflow_tool_source_repository.py @@ -374,7 +374,7 @@ def graph(provider_id: str) -> dict[str, list[dict[str, object]]]: 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="require Engine-managed container execution"): + with pytest.raises(ValueError, match="Human Input nodes are not supported"): service.load( tenant_id=_TENANT_ID, graph_config=graph(parent_id), @@ -382,7 +382,7 @@ def graph(provider_id: str) -> dict[str, list[dict[str, object]]]: snapshot=materialized.snapshot(), ) else: - with pytest.raises(ValueError, match="require Engine-managed container execution"): + 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) 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..816aceb6de9423 --- /dev/null +++ b/api/tests/unit_tests/services/workflow/test_pipeline_workflow_tools.py @@ -0,0 +1,222 @@ +"""Pipeline tool lifecycle through the real Engine and SQLAlchemy repositories.""" + +import json +from unittest.mock import Mock + +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 + ) + collect_task = Mock() + 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 + collect_task.assert_called_once_with( + 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 d95c1f2df74c10..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 @@ -804,7 +804,7 @@ def test_legacy_caller_rejects_human_input_before_creating_run_or_form(sqlite_en 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="require Engine-managed container execution"): + 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 From e375a3d93a4b4545b2cb3e9fd5bf64349aa7a82b Mon Sep 17 00:00:00 2001 From: WH-2099 Date: Tue, 20 Oct 2026 09:17:39 +0000 Subject: [PATCH 16/16] test(workflow): record pipeline collection calls without mocks --- .../workflow/test_pipeline_workflow_tools.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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 index 816aceb6de9423..2696f03daf6abb 100644 --- a/api/tests/unit_tests/services/workflow/test_pipeline_workflow_tools.py +++ b/api/tests/unit_tests/services/workflow/test_pipeline_workflow_tools.py @@ -1,7 +1,6 @@ """Pipeline tool lifecycle through the real Engine and SQLAlchemy repositories.""" import json -from unittest.mock import Mock import pytest from sqlalchemy import Engine, select @@ -193,7 +192,11 @@ def test_pipeline_retires_nested_agent_workspace_when_run_ends( monkeypatch.setattr( "core.workflow.nodes.agent_v2.runtime_request_builder.resolve_model_context_window", lambda **_kwargs: None ) - collect_task = Mock() + 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) @@ -212,9 +215,15 @@ def test_pipeline_retires_nested_agent_workspace_when_run_ends( 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 - collect_task.assert_called_once_with( - tenant_id="tenant", workspace_ids=[workspace_id], binding_ids=[], home_snapshot_ids=[], purge_agent_ids=[] - ) + 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: