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