From f4ec608ef42fa50521e28b0723fa964d4ff1a5dd Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Fri, 3 Jul 2026 11:45:21 +0800 Subject: [PATCH] chore(api): upgrade graphon to v0.6.0, migrate HITL logic back to Dify (#38247) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../sdd/hitl-timeout-semantics-impl-report.md | 30 ++ api/controllers/console/app/workflow_run.py | 2 +- .../service_api/app/human_input_form.py | 2 +- api/controllers/web/human_input_form.py | 2 +- .../advanced_chat/generate_task_pipeline.py | 2 +- .../common/workflow_response_converter.py | 2 +- api/core/app/apps/workflow_app_runner.py | 13 +- api/core/app/entities/queue_entities.py | 2 +- api/core/app/entities/task_entities.py | 6 +- .../app/layers/pause_state_persist_layer.py | 12 +- api/core/entities/execution_extra_content.py | 2 +- api/core/plugin/impl/model_runtime.py | 20 +- .../repositories/human_input_repository.py | 19 +- api/core/workflow/human_input_policy.py | 12 +- api/core/workflow/node_factory.py | 22 +- api/core/workflow/node_runtime.py | 32 +- .../workflow/nodes/agent_v2/agent_node.py | 26 +- .../workflow/nodes/agent_v2/ask_human_hitl.py | 17 +- .../nodes/agent_v2/ask_human_resume.py | 21 +- .../workflow/nodes/human_input/__init__.py | 50 +++ api/core/workflow/nodes/human_input/_exc.py | 22 ++ .../workflow/nodes/human_input/boundary.py | 61 +++ .../workflow/nodes/human_input/callback.py | 329 +++++++++++++++ .../workflow/nodes/human_input/entities.py | 374 ++++++++++++++++++ api/core/workflow/nodes/human_input/enums.py | 76 ++++ .../nodes/human_input/pause_reason.py | 49 +++ .../nodes/human_input/session_binding.py | 16 + api/models/human_input.py | 2 +- api/models/workflow.py | 38 +- api/pyproject.toml | 2 +- .../api_workflow_run_repository.py | 5 +- api/repositories/entities/workflow_pause.py | 4 +- .../sqlalchemy_api_workflow_run_repository.py | 72 +++- ...hemy_execution_extra_content_repository.py | 8 +- .../human_input_file_upload_service.py | 2 +- api/services/human_input_service.py | 10 +- .../workflow_event_snapshot_service.py | 7 +- api/services/workflow_service.py | 106 +++-- api/tasks/human_input_timeout_tasks.py | 2 +- .../controllers/web/test_human_input_form.py | 11 +- .../test_human_input_form_repository_impl.py | 2 +- .../test_human_input_resume_node_execution.py | 16 +- .../helpers/execution_extra_content.py | 2 +- ..._sqlalchemy_api_workflow_run_repository.py | 239 ++++++++++- ...hemy_execution_extra_content_repository.py | 4 +- .../test_human_input_delivery_test.py | 4 +- .../test_human_input_file_upload_service.py | 2 +- .../test_workflow_event_snapshot_service.py | 6 +- .../test_mail_human_input_delivery_task.py | 2 +- .../app/test_workflow_pause_details_api.py | 4 +- .../service_api/app/test_hitl_service_api.py | 12 +- .../controllers/web/test_human_input_form.py | 4 +- .../test_generate_response_converter.py | 4 +- .../test_generate_task_pipeline.py | 2 +- .../test_generate_task_pipeline_core.py | 6 +- .../core/app/apps/test_pause_resume.py | 2 +- .../app/apps/test_workflow_app_runner_core.py | 19 +- .../test_workflow_app_runner_notifications.py | 18 +- .../app/apps/test_workflow_pause_events.py | 37 +- .../layers/test_pause_state_persist_layer.py | 61 ++- .../test_entities_execution_extra_content.py | 2 +- .../test_human_input_form_repository_impl.py | 4 +- .../test_human_input_repository.py | 4 +- .../test_parallel_human_input_join_resume.py | 44 ++- .../nodes/agent_v2/test_agent_node.py | 14 +- .../nodes/agent_v2/test_ask_human_hitl.py | 9 +- .../nodes/agent_v2/test_ask_human_resume.py | 30 +- .../human_input/test_dify_owned_contracts.py | 139 +++++++ .../nodes/human_input/test_entities.py | 35 +- .../test_human_input_form_filled_event.py | 75 ++-- .../nodes/test_document_extractor_node.py | 2 +- .../workflow/test_enrich_pause_reasons.py | 27 +- .../test_form_input_serialization_compat.py | 14 +- .../workflow/test_human_input_callback.py | 188 +++++++++ .../core/workflow/test_human_input_policy.py | 4 +- .../core/workflow/test_node_factory.py | 23 +- .../core/workflow/test_node_runtime.py | 2 +- .../unit_tests/libs/_human_input/support.py | 4 +- .../libs/_human_input/test_form_service.py | 4 +- .../libs/_human_input/test_models.py | 4 +- ..._sqlalchemy_api_workflow_run_repository.py | 76 +++- ...hemy_execution_extra_content_repository.py | 21 +- .../test_human_input_file_upload_service.py | 2 +- .../services/test_human_input_service.py | 6 +- .../services/test_workflow_service.py | 53 +-- .../test_workflow_event_snapshot_service.py | 6 +- .../test_workflow_human_input_delivery.py | 2 +- .../tasks/test_human_input_timeout_tasks.py | 2 +- api/uv.lock | 9 +- 89 files changed, 2345 insertions(+), 396 deletions(-) create mode 100644 .superpowers/sdd/hitl-timeout-semantics-impl-report.md create mode 100644 api/core/workflow/nodes/human_input/__init__.py create mode 100644 api/core/workflow/nodes/human_input/_exc.py create mode 100644 api/core/workflow/nodes/human_input/boundary.py create mode 100644 api/core/workflow/nodes/human_input/callback.py create mode 100644 api/core/workflow/nodes/human_input/entities.py create mode 100644 api/core/workflow/nodes/human_input/enums.py create mode 100644 api/core/workflow/nodes/human_input/pause_reason.py create mode 100644 api/core/workflow/nodes/human_input/session_binding.py create mode 100644 api/tests/unit_tests/core/workflow/nodes/human_input/test_dify_owned_contracts.py create mode 100644 api/tests/unit_tests/core/workflow/test_human_input_callback.py diff --git a/.superpowers/sdd/hitl-timeout-semantics-impl-report.md b/.superpowers/sdd/hitl-timeout-semantics-impl-report.md new file mode 100644 index 00000000000..ba36a15d7dc --- /dev/null +++ b/.superpowers/sdd/hitl-timeout-semantics-impl-report.md @@ -0,0 +1,30 @@ +# HITL timeout semantics implementation report + +## What changed + +- Updated `api/core/workflow/nodes/human_input/callback.py` so `DifyHITLCallback` now preserves Dify's timeout split at the boundary: + - `HumanInputFormStatus.TIMEOUT` returns the graphon timeout branch via `Expired(selected_handle="__timeout__", ...)`. + - `HumanInputFormStatus.EXPIRED` is treated as an invalid resume state and raises `AssertionError`. + - `HumanInputFormStatus.WAITING` with a past global deadline is treated as an invalid resume state and raises `AssertionError`. + - `HumanInputFormStatus.WAITING` with only the node-level deadline expired still returns the timeout branch. +- Added `created_at` to `HumanInputFormEntity` and `_HumanInputFormEntityImpl` so the callback can compute the global deadline using Dify's shared `HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS` invariant. +- Kept the submitted and pause flows unchanged. +- Added focused unit coverage in `api/tests/unit_tests/core/workflow/test_human_input_callback.py` for: + - node timeout branch + - global expiration rejection + - waiting-form past node deadline timeout + - waiting-form past global deadline rejection + +## Verification + +- `uv run --project api pytest -o addopts='' api/tests/unit_tests/core/workflow/test_human_input_callback.py api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py -q` +- `git diff --check` + +## Result + +- The focused test set is expected to pass with the new `created_at` boundary in place. +- No unrelated files were modified. + +## Concerns + +- The callback now fails fast on invalid resume states by design. That is intentional, but any caller that previously relied on `EXPIRED` being mapped to the timeout branch will now see an assertion failure instead. diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py index 374537229ca..a71aa444e27 100644 --- a/api/controllers/console/app/workflow_run.py +++ b/api/controllers/console/app/workflow_run.py @@ -23,6 +23,7 @@ from controllers.console.wraps import ( with_current_user, ) from core.workflow.human_input_forms import load_form_tokens_by_form_id as _load_form_tokens_by_form_id +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from extensions.ext_database import db from fields.base import ResponseModel from fields.workflow_run_fields import ( @@ -33,7 +34,6 @@ from fields.workflow_run_fields import ( WorkflowRunNodeExecutionResponse, WorkflowRunPaginationResponse, ) -from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage from libs.custom_inputs import time_duration diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py index 07cc1eeab6d..53069644b55 100644 --- a/api/controllers/service_api/app/human_input_form.py +++ b/api/controllers/service_api/app/human_input_form.py @@ -21,9 +21,9 @@ from controllers.service_api import service_api_ns from controllers.service_api.schema import expect_with_user from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface +from core.workflow.nodes.human_input.entities import FormInputConfig from extensions.ext_database import db from fields.base import ResponseModel -from graphon.nodes.human_input.entities import FormInputConfig from libs.helper import to_timestamp from models.model import App, EndUser from services.human_input_service import Form, FormNotFoundError, HumanInputService diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index 14b982dd23b..f99a6fa3f83 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -21,8 +21,8 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.web import web_ns from controllers.web.error import WebFormRateLimitExceededError from controllers.web.site import serialize_app_site_payload +from core.workflow.nodes.human_input.entities import FormInputConfig from extensions.ext_database import db -from graphon.nodes.human_input.entities import FormInputConfig from libs.helper import RateLimiter, extract_remote_ip, to_timestamp from models.account import TenantStatus from models.model import App, Site diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index c0d6952871e..2c9350ca774 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -74,9 +74,9 @@ from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk from core.ops.ops_trace_manager import TraceQueueManager from core.repositories.human_input_repository import HumanInputFormRepositoryImpl from core.workflow.file_reference import resolve_file_record_id +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import build_system_variables from extensions.ext_database import db -from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.model_runtime.utils.encoders import jsonable_encoder diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 67f37e78ab9..236b8c58d3a 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -60,11 +60,11 @@ from core.workflow.human_input_policy import ( enrich_human_input_pause_reasons, resolve_human_input_pause_reason_inputs, ) +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import SystemVariableKey, system_variables_to_mapping from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_database import db from graphon.entities import WorkflowStartReason -from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import ( BuiltinNodeTypes, WorkflowExecutionStatus, diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 3dae32b742b..01dcaef10db 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -34,12 +34,15 @@ from core.app.entities.queue_entities import ( QueueWorkflowSucceededEvent, ) from core.rag.entities import RetrievalSourceMetadata +from core.repositories.human_input_repository import HumanInputFormSubmissionRepository from core.workflow.node_factory import ( DifyGraphInitContext, DifyNodeFactory, get_default_root_node_id, resolve_workflow_node_class, ) +from core.workflow.nodes.human_input.boundary import enrich_graph_pause_reasons +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import ( build_bootstrap_variables, default_system_variables, @@ -51,7 +54,6 @@ from core.workflow.variable_pool_initializer import add_variables_to_pool from core.workflow.workflow_entry import WorkflowEntry from core.workflow.workflow_run_outputs import project_node_outputs_for_workflow_run from graphon.entities.graph_config import NodeConfigDictAdapter -from graphon.entities.pause_reason import HumanInputRequired from graphon.graph import Graph from graphon.graph_engine.layers import GraphEngineLayer from graphon.graph_events import ( @@ -426,10 +428,15 @@ class WorkflowBasedAppRunner: case GraphRunPausedEvent(): runtime_state = workflow_entry.graph_engine.graph_runtime_state paused_nodes = runtime_state.get_paused_nodes() - self._enqueue_human_input_notifications(event.reasons) + enriched_reasons = enrich_graph_pause_reasons( + reasons=event.reasons, + form_repository=HumanInputFormSubmissionRepository(), + variable_pool=runtime_state.variable_pool, + ) + self._enqueue_human_input_notifications(enriched_reasons) self._publish_event( QueueWorkflowPausedEvent( - reasons=event.reasons, + reasons=enriched_reasons, outputs=event.outputs, paused_nodes=paused_nodes, ) diff --git a/api/core/app/entities/queue_entities.py b/api/core/app/entities/queue_entities.py index 102d92ccf9e..6bbffbbb160 100644 --- a/api/core/app/entities/queue_entities.py +++ b/api/core/app/entities/queue_entities.py @@ -7,8 +7,8 @@ from pydantic import BaseModel, ConfigDict, Field from core.app.entities.agent_strategy import AgentStrategyInfo from core.rag.entities import RetrievalSourceMetadata +from core.workflow.nodes.human_input.pause_reason import PauseReason from graphon.entities import WorkflowStartReason -from graphon.entities.pause_reason import PauseReason from graphon.enums import NodeType, WorkflowNodeExecutionMetadataKey from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk from graphon.variables.segments import Segment diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index ca5a26db55b..eb96063a6e6 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -6,11 +6,11 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue from core.app.entities.agent_strategy import AgentStrategyInfo from core.rag.entities import RetrievalSourceMetadata +from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig +from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType from graphon.entities import WorkflowStartReason -from graphon.entities.pause_reason import PauseReasonType from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage -from graphon.nodes.human_input.entities import FormInputConfig, UserActionConfig class AnnotationReplyAccount(BaseModel): @@ -307,7 +307,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel): ``human_input_required`` events are available. """ - TYPE: Literal[PauseReasonType.HUMAN_INPUT_REQUIRED] = PauseReasonType.HUMAN_INPUT_REQUIRED + TYPE: Literal[DifyHITLEventType.HUMAN_INPUT_REQUIRED] = DifyHITLEventType.HUMAN_INPUT_REQUIRED form_id: str node_id: str node_title: str diff --git a/api/core/app/layers/pause_state_persist_layer.py b/api/core/app/layers/pause_state_persist_layer.py index d6517218992..2a13c73eccf 100644 --- a/api/core/app/layers/pause_state_persist_layer.py +++ b/api/core/app/layers/pause_state_persist_layer.py @@ -6,6 +6,8 @@ from sqlalchemy import Engine from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity +from core.repositories.human_input_repository import HumanInputFormSubmissionRepository +from core.workflow.nodes.human_input.boundary import enrich_graph_pause_reasons from core.workflow.system_variables import SystemVariableKey, get_system_text from graphon.graph_engine.layers import GraphEngineLayer from graphon.graph_events import GraphEngineEvent, GraphRunPausedEvent @@ -126,12 +128,20 @@ class PauseStatePersistenceLayer(GraphEngineLayer): SystemVariableKey.WORKFLOW_EXECUTION_ID, ) assert workflow_run_id is not None + # NOTE(QuantumGhost): Dify owns the pause-reason semantics that cross the + # persistence boundary. Graphon session ids are translated back to form ids + # here so repository/model layers only handle Dify-owned pause reasons. + pause_reasons = enrich_graph_pause_reasons( + reasons=event.reasons, + form_repository=HumanInputFormSubmissionRepository(), + variable_pool=self.graph_runtime_state.variable_pool, + ) repo = self._get_repo() repo.create_workflow_pause( workflow_run_id=workflow_run_id, state_owner_user_id=self._state_owner_user_id, state=state.dumps(), - pause_reasons=event.reasons, + pause_reasons=pause_reasons, ) @override diff --git a/api/core/entities/execution_extra_content.py b/api/core/entities/execution_extra_content.py index 43252ccb2ce..5969f103e07 100644 --- a/api/core/entities/execution_extra_content.py +++ b/api/core/entities/execution_extra_content.py @@ -5,7 +5,7 @@ from typing import Any, TypeAlias from pydantic import BaseModel, ConfigDict, Field, JsonValue -from graphon.nodes.human_input.entities import FormInputConfig, UserActionConfig +from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig from models.execution_extra_content import ExecutionContentType diff --git a/api/core/plugin/impl/model_runtime.py b/api/core/plugin/impl/model_runtime.py index c1b976d8f4f..021d0005e85 100644 --- a/api/core/plugin/impl/model_runtime.py +++ b/api/core/plugin/impl/model_runtime.py @@ -2,7 +2,7 @@ from __future__ import annotations import hashlib import logging -from collections.abc import Generator, Iterable, Sequence +from collections.abc import Generator, Iterable, Mapping, Sequence from typing import IO, Any, Literal, cast, overload, override from pydantic import ValidationError @@ -285,6 +285,7 @@ class PluginModelRuntime(ModelRuntime): tools: list[PromptMessageTool] | None, stop: Sequence[str] | None, stream: Literal[False], + request_metadata: Mapping[str, object] | None = None, ) -> LLMResult: ... @overload @@ -299,6 +300,7 @@ class PluginModelRuntime(ModelRuntime): tools: list[PromptMessageTool] | None, stop: Sequence[str] | None, stream: Literal[True], + request_metadata: Mapping[str, object] | None = None, ) -> Generator[LLMResultChunk, None, None]: ... @override @@ -313,7 +315,9 @@ class PluginModelRuntime(ModelRuntime): tools: list[PromptMessageTool] | None, stop: Sequence[str] | None, stream: bool, + request_metadata: Mapping[str, object] | None = None, ) -> LLMResult | Generator[LLMResultChunk, None, None]: + del request_metadata plugin_id, provider_name = self._split_provider(provider) result = self.client.invoke_llm( tenant_id=self.tenant_id, @@ -489,7 +493,9 @@ class PluginModelRuntime(ModelRuntime): credentials: dict[str, Any], texts: list[str], input_type: EmbeddingInputType, + request_metadata: Mapping[str, object] | None = None, ) -> EmbeddingResult: + del request_metadata plugin_id, provider_name = self._split_provider(provider) return self.client.invoke_text_embedding( tenant_id=self.tenant_id, @@ -511,7 +517,9 @@ class PluginModelRuntime(ModelRuntime): credentials: dict[str, Any], documents: list[dict[str, Any]], input_type: EmbeddingInputType, + request_metadata: Mapping[str, object] | None = None, ) -> EmbeddingResult: + del request_metadata plugin_id, provider_name = self._split_provider(provider) return self.client.invoke_multimodal_embedding( tenant_id=self.tenant_id, @@ -555,7 +563,9 @@ class PluginModelRuntime(ModelRuntime): docs: list[str], score_threshold: float | None, top_n: int | None, + request_metadata: Mapping[str, object] | None = None, ) -> RerankResult: + del request_metadata plugin_id, provider_name = self._split_provider(provider) return self.client.invoke_rerank( tenant_id=self.tenant_id, @@ -581,7 +591,9 @@ class PluginModelRuntime(ModelRuntime): docs: list[MultimodalRerankInput], score_threshold: float | None, top_n: int | None, + request_metadata: Mapping[str, object] | None = None, ) -> RerankResult: + del request_metadata plugin_id, provider_name = self._split_provider(provider) return self.client.invoke_multimodal_rerank( tenant_id=self.tenant_id, @@ -605,7 +617,9 @@ class PluginModelRuntime(ModelRuntime): credentials: dict[str, Any], content_text: str, voice: str, + request_metadata: Mapping[str, object] | None = None, ) -> Iterable[bytes]: + del request_metadata plugin_id, provider_name = self._split_provider(provider) return self.client.invoke_tts( tenant_id=self.tenant_id, @@ -646,7 +660,9 @@ class PluginModelRuntime(ModelRuntime): model: str, credentials: dict[str, Any], file: IO[bytes], + request_metadata: Mapping[str, object] | None = None, ) -> str: + del request_metadata plugin_id, provider_name = self._split_provider(provider) return self.client.invoke_speech_to_text( tenant_id=self.tenant_id, @@ -666,7 +682,9 @@ class PluginModelRuntime(ModelRuntime): model: str, credentials: dict[str, Any], text: str, + request_metadata: Mapping[str, object] | None = None, ) -> bool: + del request_metadata plugin_id, provider_name = self._split_provider(provider) return self.client.invoke_moderation( tenant_id=self.tenant_id, diff --git a/api/core/repositories/human_input_repository.py b/api/core/repositories/human_input_repository.py index 599cc643ea4..ac3aa46fc21 100644 --- a/api/core/repositories/human_input_repository.py +++ b/api/core/repositories/human_input_repository.py @@ -17,8 +17,8 @@ from core.workflow.human_input_adapter import ( InteractiveSurfaceDeliveryMethod, is_human_input_webapp_enabled, ) -from graphon.nodes.human_input.entities import FormDefinition, HumanInputNodeData -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from core.workflow.nodes.human_input.entities import FormDefinition, HumanInputNodeData +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.datetime_utils import naive_utc_now from libs.uuid_utils import uuidv7 from models.account import Account, TenantAccountJoin @@ -93,6 +93,9 @@ class HumanInputFormEntity(Protocol): @property def selected_action_id(self) -> str | None: ... + @property + def created_at(self) -> datetime: ... + @property def submitted_data(self) -> Mapping[str, Any] | None: ... @@ -178,6 +181,11 @@ class _HumanInputFormEntityImpl(HumanInputFormEntity): def selected_action_id(self) -> str | None: return self._form_model.selected_action_id + @property + @override + def created_at(self) -> datetime: + return self._form_model.created_at + @property @override def submitted_data(self) -> Mapping[str, Any] | None: @@ -572,6 +580,13 @@ class HumanInputFormSubmissionRepository: return None return HumanInputFormRecord.from_models(recipient_model.form, recipient_model) + def get_by_form_id(self, form_id: str) -> HumanInputFormRecord | None: + with session_factory.create_session() as session: + form_model = session.get(HumanInputForm, form_id) + if form_model is None: + return None + return HumanInputFormRecord.from_models(form_model, None) + def get_by_form_id_and_recipient_type( self, form_id: str, diff --git a/api/core/workflow/human_input_policy.py b/api/core/workflow/human_input_policy.py index d6f7df52354..6bc415649ed 100644 --- a/api/core/workflow/human_input_policy.py +++ b/api/core/workflow/human_input_policy.py @@ -4,9 +4,13 @@ from collections.abc import Mapping, Sequence from enum import StrEnum from typing import Any, NamedTuple -from graphon.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType -from graphon.nodes.human_input.entities import FormInputConfig, SelectInputConfig -from graphon.nodes.human_input.enums import ValueSourceType +from core.workflow.nodes.human_input.entities import FormInputConfig, SelectInputConfig +from core.workflow.nodes.human_input.enums import ValueSourceType +from core.workflow.nodes.human_input.pause_reason import ( + DifyHITLEventType, + HumanInputRequired, + PauseReason, +) from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool from graphon.variables import ArrayStringSegment from models.human_input import ApprovalChannel, RecipientType @@ -97,7 +101,7 @@ def enrich_human_input_pause_reasons( enriched: list[dict[str, Any]] = [] for reason in reasons: updated = dict(reason) - if updated.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED: + if updated.get("TYPE") == DifyHITLEventType.HUMAN_INPUT_REQUIRED: form_id = updated.get("form_id") if isinstance(form_id, str): disposition = dispositions_by_form_id.get(form_id) diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index ebeb189ab19..991e79cdc61 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -43,6 +43,8 @@ from core.workflow.nodes.agent_v2 import DifyAgentNode from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingResolver from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder +from core.workflow.nodes.human_input.callback import DifyHITLCallback +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 graphon.entities.base_node_data import BaseNodeData @@ -409,9 +411,9 @@ class DifyNodeFactory(NodeFactory): "file_reference_factory": self._file_reference_factory, }, BuiltinNodeTypes.HUMAN_INPUT: lambda: { - "runtime": self._human_input_runtime, - "file_reference_factory": self._file_reference_factory, - "form_repository": self._human_input_runtime.build_form_repository(), + "hitl_callback": self._build_human_input_callback( + node_data=DifyHumanInputNodeData.model_validate(adapted_node_config["data"]) + ), }, BuiltinNodeTypes.LLM: lambda: self._build_llm_compatible_node_init_kwargs( node_class=node_class, @@ -515,6 +517,20 @@ class DifyNodeFactory(NodeFactory): "message_transformer": self._agent_message_transformer, } + def _build_human_input_callback( + self, + *, + node_data: DifyHumanInputNodeData, + ) -> DifyHITLCallback: + return DifyHITLCallback( + form_repository=self._human_input_runtime.build_form_repository(), + node_data=node_data, + conversation_id=self._conversation_id(), + delivery_methods=self._human_input_runtime._resolve_delivery_methods(node_data=node_data), + display_in_ui=self._human_input_runtime._display_in_ui(node_data=node_data), + file_reference_factory=self._file_reference_factory, + ) + def _build_llm_compatible_node_init_kwargs( self, *, diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index 7d9b9d854b2..233f404b0a4 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Generator, Mapping, Sequence from dataclasses import dataclass +from enum import Enum from typing import TYPE_CHECKING, Any, Literal, cast, overload, override from pydantic import JsonValue @@ -25,6 +26,7 @@ from core.plugin.impl.plugin import PluginInstaller from core.prompt.utils.prompt_message_util import PromptMessageUtil from core.repositories.human_input_repository import ( FormCreateParams, + HumanInputFormEntity, HumanInputFormRepository, HumanInputFormRepositoryImpl, ) @@ -35,6 +37,12 @@ from core.tools.tool_file_manager import ToolFileManager from core.tools.tool_manager import ToolManager from core.tools.utils.message_transformer import ToolFileMessageTransformer from core.workflow.file_reference import build_file_reference +from core.workflow.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + FormInputConfig, + HumanInputNodeData, +) from extensions.ext_database import db from factories import file_factory from graphon.file import File, FileTransferMethod, FileType @@ -50,12 +58,6 @@ from graphon.model_runtime.entities.llm_entities import ( from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool from graphon.model_runtime.entities.model_entities import AIModelEntity from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel -from graphon.nodes.human_input.entities import ( - FileInputConfig, - FileListInputConfig, - FormInputConfig, - HumanInputNodeData, -) from graphon.nodes.llm.runtime_protocols import ( LLMPollingCapableProtocol, LLMProtocol, @@ -63,11 +65,7 @@ from graphon.nodes.llm.runtime_protocols import ( RetrieverAttachmentLoaderProtocol, ) from graphon.nodes.protocols import FileReferenceFactoryProtocol, HttpClientProtocol, ToolFileManagerProtocol -from graphon.nodes.runtime import ( - HumanInputFormStateProtocol, - HumanInputNodeRuntimeProtocol, - ToolNodeRuntimeProtocol, -) +from graphon.nodes.runtime import ToolNodeRuntimeProtocol from graphon.nodes.tool.exc import ToolNodeError, ToolRuntimeInvocationError, ToolRuntimeResolutionError from graphon.nodes.tool_runtime_entities import ( ToolRuntimeHandle, @@ -766,7 +764,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): return ToolRuntimeInvocationError(str(exc)) -class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): +class DifyHumanInputNodeRuntime: def __init__( self, run_context: Mapping[str, Any] | DifyRunContext, @@ -785,7 +783,9 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): invoke_from = self._run_context.invoke_from if isinstance(invoke_from, str): return invoke_from - return str(getattr(invoke_from, "value", invoke_from)) + if isinstance(invoke_from, Enum): + return str(invoke_from.value) + return str(invoke_from) def _resolve_delivery_methods(self, *, node_data: HumanInputNodeData) -> Sequence[DeliveryChannelConfig]: invoke_source = self._invoke_source() @@ -830,8 +830,7 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): form_repository=form_repository, ) - @override - def get_form(self, *, node_id: str) -> HumanInputFormStateProtocol | None: + def get_form(self, *, node_id: str) -> HumanInputFormEntity | None: repo = self.build_form_repository() return repo.get_form(node_id) @@ -852,7 +851,6 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): ) return restored_data - @override def create_form( self, *, @@ -860,7 +858,7 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): node_data: HumanInputNodeData, rendered_content: str, resolved_default_values: Mapping[str, Any], - ) -> HumanInputFormStateProtocol: + ) -> HumanInputFormEntity: repo = self.build_form_repository() params = FormCreateParams( workflow_execution_id=self._workflow_execution_id_getter() if self._workflow_execution_id_getter else None, diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py index 1268cde65bd..41778a0ba50 100644 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ b/api/core/workflow/nodes/agent_v2/agent_node.py @@ -25,8 +25,10 @@ from clients.agent_backend import ( ) from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from core.workflow.nodes.human_input.session_binding import default_session_binding from core.workflow.system_variables import SystemVariableKey, get_system_text -from graphon.entities.pause_reason import HumanInputRequired, SchedulingPause +from graphon.entities.pause_reason import HitlRequired, SchedulingPause from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent from graphon.nodes.base.node import Node @@ -113,6 +115,16 @@ class DifyAgentNode(Node[DifyAgentNodeData]): def populate_start_event(self, event) -> None: event.extras["agent_node"] = {"version": "2", "agent_node_kind": self.node_data.agent_node_kind} + @staticmethod + def _to_graph_pause_reason(reason: HumanInputRequired | SchedulingPause) -> HitlRequired | SchedulingPause: + if isinstance(reason, HumanInputRequired): + return HitlRequired( + session_id=default_session_binding.issue_session_id_for_form(form_id=reason.form_id), + node_id=reason.node_id, + node_title=reason.node_title, + ) + return reason + @override def _run(self) -> Generator[NodeEventBase, None, None]: dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY)) @@ -193,7 +205,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): node_id=self._node_id, ) if resume_outcome is not None and resume_outcome.repause is not None: - yield PauseRequestedEvent(reason=resume_outcome.repause) + yield PauseRequestedEvent(reason=self._to_graph_pause_reason(resume_outcome.repause)) return if ( resume_outcome is not None @@ -295,7 +307,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): # form is built *before* the snapshot is saved so its id can be # persisted as the pause correlation (ENG-637). try: - pause_reason: HumanInputRequired | SchedulingPause | None = build_ask_human_pause_reason( + pause_request = build_ask_human_pause_reason( deferred_tool_call=terminal_event.deferred_tool_call, node_id=self._node_id, default_node_title=bundle.agent.name or self._node_id, @@ -321,9 +333,11 @@ class DifyAgentNode(Node[DifyAgentNodeData]): # deferred_tool_results from the submitted form. pending_form_id: str | None = None pending_tool_call_id: str | None = None - if isinstance(pause_reason, HumanInputRequired): - pending_form_id = pause_reason.form_id + pause_reason: HumanInputRequired | SchedulingPause + if pause_request is not None: + pending_form_id = pause_request.form_id pending_tool_call_id = terminal_event.deferred_tool_call.tool_call_id + pause_reason = pause_request else: pause_reason = SchedulingPause( message=terminal_event.message @@ -338,7 +352,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): pending_form_id=pending_form_id, pending_tool_call_id=pending_tool_call_id, ) - yield PauseRequestedEvent(reason=pause_reason) + yield PauseRequestedEvent(reason=self._to_graph_pause_reason(pause_reason)) return # Non-success terminal (failed / cancelled) skips per-output diff --git a/api/core/workflow/nodes/agent_v2/ask_human_hitl.py b/api/core/workflow/nodes/agent_v2/ask_human_hitl.py index 0b535cf4f10..56b3b59d2ae 100644 --- a/api/core/workflow/nodes/agent_v2/ask_human_hitl.py +++ b/api/core/workflow/nodes/agent_v2/ask_human_hitl.py @@ -2,11 +2,12 @@ ENG-636. When an Agent backend run ends with a deferred ``dify.ask_human`` tool call, the workflow Agent node pauses the *outer* workflow through the very same -``HumanInputRequired`` form mechanism the Human Input node uses — reusing the -form repository, delivery channels, and submission endpoints unchanged. This -module is a pure translation layer: model-facing ask_human tool args become -graphon form entities (``HumanInputNodeData`` / ``FormInputConfig`` / -``UserActionConfig``) plus Dify delivery configs. It adds no new HITL behavior. +HITL form path the Human Input node uses — reusing the form repository, +delivery channels, and submission endpoints unchanged. This module is a pure +translation layer: model-facing ask_human tool args become graphon form +entities (``HumanInputNodeData`` / ``FormInputConfig`` / ``UserActionConfig``) +plus Dify delivery configs and the enriched Dify-side pause reason payload. +The Graphon ``HitlRequired`` conversion stays at the node boundary. The agent-side ``dify.ask_human`` contract is richer than the workflow form schema in two places, handled here without widening the form vocabulary: @@ -48,8 +49,7 @@ from core.workflow.human_input_adapter import ( ExternalRecipient, InteractiveSurfaceDeliveryMethod, ) -from graphon.entities.pause_reason import HumanInputRequired -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( FileInputConfig, FileListInputConfig, FormInputConfig, @@ -60,7 +60,8 @@ from graphon.nodes.human_input.entities import ( StringSource, UserActionConfig, ) -from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType +from core.workflow.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from models.agent_config_entities import AgentHumanContactConfig # Default ask_human tool name (see ``DifyAskHumanLayerConfig.tool_name``). The diff --git a/api/core/workflow/nodes/agent_v2/ask_human_resume.py b/api/core/workflow/nodes/agent_v2/ask_human_resume.py index 13c9602722b..84d473e2c29 100644 --- a/api/core/workflow/nodes/agent_v2/ask_human_resume.py +++ b/api/core/workflow/nodes/agent_v2/ask_human_resume.py @@ -7,9 +7,10 @@ agent-side ``dify.ask_human`` contract so the node can start a *second* Agent ru that carries the human's answer: * submitted -> AskHumanToolResult(status="submitted", action, values) -* timeout / expired -> AskHumanToolResult(status="timeout") +* timeout -> AskHumanToolResult(status="timeout") +* expired (global timeout) -> invalid resume state; the workflow must not resume * still waiting (defensive: the host resumed us early) -> re-emit the same - HumanInputRequired pause rebuilt from the stored form definition. + Dify-side ``HumanInputRequired`` pause rebuilt from the stored form definition. It only *reads* existing HITL form state — it never mutates the form or the HITL submission flow. The DB read (``resolve_ask_human_form``) is kept thin so the @@ -31,18 +32,17 @@ from pydantic import JsonValue from sqlalchemy import select from core.db.session_factory import session_factory -from graphon.entities.pause_reason import HumanInputRequired -from graphon.nodes.human_input.entities import FormDefinition -from graphon.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input.entities import FormDefinition +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from models.human_input import HumanInputForm -# A WAITING form has not been answered yet; the other terminal states map onto -# the agent-facing result status. EXPIRED (global timeout) and TIMEOUT -# (node-level) both surface to the model as "timeout" so it can react. +# A WAITING form has not been answered yet. TIMEOUT is resumable through the +# agent-facing "timeout" result, but EXPIRED is a global timeout and therefore +# an invalid resume state, matching the ordinary Human Input callback boundary. _FORM_STATUS_TO_RESULT: dict[HumanInputFormStatus, AskHumanResultStatus] = { HumanInputFormStatus.SUBMITTED: "submitted", HumanInputFormStatus.TIMEOUT: "timeout", - HumanInputFormStatus.EXPIRED: "timeout", } @@ -99,6 +99,9 @@ def map_form_to_outcome( definition = FormDefinition.model_validate_json(form_definition) if status == HumanInputFormStatus.WAITING: return AskHumanResumeOutcome(repause=_rebuild_pause(definition=definition, form_id=form_id, node_id=node_id)) + if status == HumanInputFormStatus.EXPIRED: + msg = f"cannot resume globally expired ask_human form, form_id={form_id}" + raise AssertionError(msg) result_status = _FORM_STATUS_TO_RESULT.get(status, "unavailable") if result_status != "submitted": diff --git a/api/core/workflow/nodes/human_input/__init__.py b/api/core/workflow/nodes/human_input/__init__.py new file mode 100644 index 00000000000..8709129bd26 --- /dev/null +++ b/api/core/workflow/nodes/human_input/__init__.py @@ -0,0 +1,50 @@ +from .entities import ( + FileInputConfig, + FileListInputConfig, + FormDefinition, + FormInputConfig, + HumanInputNodeData, + HumanInputSubmissionValidationError, + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + StringSource, + UserActionConfig, + validate_human_input_submission, +) +from .enums import ( + ButtonStyle, + FormInputType, + HumanInputFormKind, + HumanInputFormStatus, + TimeoutUnit, + ValueSourceType, +) +from .pause_reason import DifyHITLEventType, HumanInputRequired, PauseReason +from .session_binding import SessionBinding, default_session_binding + +__all__ = [ + "ButtonStyle", + "DifyHITLEventType", + "FileInputConfig", + "FileListInputConfig", + "FormDefinition", + "FormInputConfig", + "FormInputType", + "HumanInputFormKind", + "HumanInputFormStatus", + "HumanInputNodeData", + "HumanInputRequired", + "HumanInputSubmissionValidationError", + "ParagraphInputConfig", + "PauseReason", + "SelectInputConfig", + "SessionBinding", + "StringListSource", + "StringSource", + "TimeoutUnit", + "UserActionConfig", + "ValueSourceType", + "default_session_binding", + "validate_human_input_submission", +] diff --git a/api/core/workflow/nodes/human_input/_exc.py b/api/core/workflow/nodes/human_input/_exc.py new file mode 100644 index 00000000000..6f47569d15b --- /dev/null +++ b/api/core/workflow/nodes/human_input/_exc.py @@ -0,0 +1,22 @@ +from graphon.file.enums import FileTransferMethod + + +class InvalidConfigError(Exception): + pass + + +class InvalidSubmittedDataError(Exception): + pass + + +class InvalidTransferMethodError(InvalidConfigError): + transfer_method: FileTransferMethod + + def __init__(self, transfer_method: FileTransferMethod) -> None: + self.transfer_method = transfer_method + super().__init__(f"invalid file transfer method: {transfer_method}") + + +class ExtensionsNotSetErrorValueError(InvalidConfigError): + def __init__(self) -> None: + super().__init__("allowed_file_extensions not set") diff --git a/api/core/workflow/nodes/human_input/boundary.py b/api/core/workflow/nodes/human_input/boundary.py new file mode 100644 index 00000000000..ff61f59a318 --- /dev/null +++ b/api/core/workflow/nodes/human_input/boundary.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from core.repositories.human_input_repository import HumanInputFormSubmissionRepository +from core.workflow.human_input_policy import resolve_variable_select_input_options +from graphon.entities.pause_reason import HitlRequired, SchedulingPause +from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool + +from .pause_reason import HumanInputRequired, PauseReason +from .session_binding import default_session_binding + + +class HumanInputPauseReasonResolutionError(LookupError): + """Raised when a graph pause reason cannot be resolved into Dify-owned form state.""" + + +def enrich_graph_pause_reasons( + *, + reasons: Sequence[HitlRequired | PauseReason], + form_repository: HumanInputFormSubmissionRepository, + variable_pool: ReadOnlyVariablePool | None, +) -> list[PauseReason]: + enriched: list[PauseReason] = [] + for reason in reasons: + if isinstance(reason, HitlRequired): + enriched_reason = _enrich_hitl_required( + reason=reason, + form_repository=form_repository, + variable_pool=variable_pool, + ) + if enriched_reason is not None: + enriched.append(enriched_reason) + continue + if isinstance(reason, HumanInputRequired | SchedulingPause): + enriched.append(reason) + return enriched + + +def _enrich_hitl_required( + *, + reason: HitlRequired, + form_repository: HumanInputFormSubmissionRepository, + variable_pool: ReadOnlyVariablePool | None, +) -> HumanInputRequired: + form_id = default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id) + record = form_repository.get_by_form_id(form_id) + if record is None: + raise HumanInputPauseReasonResolutionError( + f"missing human input form while enriching pause reason: form_id={form_id}, session_id={reason.session_id}" + ) + + return HumanInputRequired( + form_id=record.form_id, + form_content=record.rendered_content, + inputs=resolve_variable_select_input_options(record.definition.inputs, variable_pool=variable_pool), + actions=list(record.definition.user_actions), + node_id=reason.node_id, + node_title=reason.node_title or record.definition.node_title or record.node_id, + resolved_default_values=dict(record.definition.default_values), + ) diff --git a/api/core/workflow/nodes/human_input/callback.py b/api/core/workflow/nodes/human_input/callback.py new file mode 100644 index 00000000000..46c6f3ceeee --- /dev/null +++ b/api/core/workflow/nodes/human_input/callback.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import json +import logging +from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta +from typing import Any + +from configs import dify_config +from core.repositories.human_input_repository import FormCreateParams, HumanInputFormEntity, HumanInputFormRepository +from core.workflow.human_input_adapter import DeliveryChannelConfig +from core.workflow.node_runtime import DifyFileReferenceFactory +from graphon.nodes.human_input.entities import Completed, Expired, HITLContext, HITLDecision, PauseRequested +from graphon.runtime import VariablePool +from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool +from graphon.variables.factory import build_segment +from graphon.variables.segments import Segment +from libs.datetime_utils import ensure_naive_utc, naive_utc_now + +from .entities import ( + FileInputConfig, + FileListInputConfig, + FormInputConfig, + HumanInputNodeData, + ParagraphInputConfig, + SelectInputConfig, +) +from .enums import HumanInputFormStatus +from .session_binding import default_session_binding + +logger = logging.getLogger(__name__) + + +def _require_template_variable_pool(pool: ReadOnlyVariablePool) -> VariablePool: + """Return the concrete graphon pool required for template expansion.""" + if isinstance(pool, VariablePool): + return pool + + msg = "human input rendering requires graphon.runtime.VariablePool for template expansion" + raise TypeError(msg) + + +def render_form_content_before_submission( + node_data: HumanInputNodeData, + *, + variable_pool: ReadOnlyVariablePool, +) -> str: + """Process form content by substituting runtime variables before pause.""" + # NOTE(QuantumGhost): This is not ideal, we should expose + # VariablePool method in Graphon. + rendered_form_content = _require_template_variable_pool(variable_pool).convert_template(node_data.form_content) + return rendered_form_content.markdown + + +def resolve_default_values( + node_data: HumanInputNodeData, + *, + variable_pool: ReadOnlyVariablePool, +) -> Mapping[str, Any]: + resolved_defaults: dict[str, Any] = {} + for form_input in node_data.inputs: + resolved_default = form_input.resolve_default_value(variable_pool) + if resolved_default is None: + continue + resolved_defaults[form_input.output_variable_name] = resolved_default.to_object() + return resolved_defaults + + +class DifyHITLCallback: + """Bridge Dify human-input semantics onto graphon HITL decisions. + + The boundary preserves Dify's split between node timeout and global + expiration: only explicit node timeout resumes along the timeout handle, + while global expiration is treated as an invalid resume state. + """ + + pause_requested_type = PauseRequested + + _OUTPUT_FIELD_ACTION_ID = "__action_id" + _OUTPUT_FIELD_ACTION_VALUE = "__action_value" + _OUTPUT_FIELD_RENDERED_CONTENT = "__rendered_content" + _TIMEOUT_HANDLE = "__timeout__" + + def __init__( + self, + *, + form_repository: HumanInputFormRepository, + node_data: HumanInputNodeData, + workflow_execution_id: str | None = None, + conversation_id: str | None = None, + delivery_methods: Sequence[DeliveryChannelConfig] = (), + display_in_ui: bool = False, + file_reference_factory: DifyFileReferenceFactory | None = None, + ) -> None: + self._form_repository = form_repository + self._session_binding = default_session_binding + self._node_data = node_data + self._workflow_execution_id = workflow_execution_id + self._conversation_id = conversation_id + self._delivery_methods = tuple(delivery_methods) + self._display_in_ui = display_in_ui + self._file_reference_factory = file_reference_factory + + def __call__(self, ctx: HITLContext) -> HITLDecision: + form = self._form_repository.get_form(ctx.node_id) + if form is None: + created = self._create_form(ctx) + return PauseRequested(session_id=self._session_binding.issue_session_id_for_form(form_id=created.id)) + + status = self._normalize_status(form.status) + if status == HumanInputFormStatus.TIMEOUT.value: + return Expired( + selected_handle=self._TIMEOUT_HANDLE, + outputs=self._build_special_outputs( + action_id="", + action_value="", + rendered_content=form.rendered_content, + ), + ) + + if status == HumanInputFormStatus.EXPIRED.value: + msg = f"cannot resume globally expired human input form, form_id={form.id}" + raise AssertionError(msg) + + if not form.submitted: + if status == HumanInputFormStatus.WAITING.value and self._is_past_global_deadline(form.created_at): + msg = f"cannot resume waiting human input form after global timeout, form_id={form.id}" + raise AssertionError(msg) + if self._is_past_node_deadline(form.expiration_time): + return Expired( + selected_handle=self._TIMEOUT_HANDLE, + outputs=self._build_special_outputs( + action_id="", + action_value="", + rendered_content=form.rendered_content, + ), + ) + return PauseRequested(session_id=self._session_binding.issue_session_id_for_form(form_id=form.id)) + + selected_action_id = form.selected_action_id + if selected_action_id is None: + msg = f"selected_action_id should not be None when form submitted, form_id={form.id}" + raise AssertionError(msg) + + submitted_data = self._restore_submitted_data(submitted_data=form.submitted_data or {}) + rendered_content = self.render_form_content_with_outputs( + form.rendered_content, + submitted_data, + self._node_data.outputs_field_names(), + self._node_data.inputs, + ) + outputs = dict(submitted_data) + outputs.update( + self._build_special_outputs( + action_id=selected_action_id, + action_value=self._node_data.must_resolve_action_value(selected_action_id), + rendered_content=rendered_content, + ) + ) + return Completed( + selected_handle=selected_action_id, + inputs=submitted_data, + outputs=outputs, + ) + + def _create_form(self, ctx: HITLContext) -> HumanInputFormEntity: + params = FormCreateParams( + workflow_execution_id=self._workflow_execution_id or ctx.workflow_execution_id, + conversation_id=self._conversation_id, + node_id=ctx.node_id, + form_config=self._node_data, + rendered_content=render_form_content_before_submission( + self._node_data, + variable_pool=ctx.variable_pool, + ), + delivery_methods=self._delivery_methods, + display_in_ui=self._display_in_ui, + resolved_default_values=dict( + resolve_default_values( + self._node_data, + variable_pool=ctx.variable_pool, + ) + ), + ) + return self._form_repository.create_form(params) + + @staticmethod + def _normalize_status(status: HumanInputFormStatus | str) -> str: + if isinstance(status, HumanInputFormStatus): + return status.value + return status + + @staticmethod + def _is_past_node_deadline(expiration_time: datetime) -> bool: + expiration_time = ensure_naive_utc(expiration_time) + return expiration_time <= naive_utc_now() + + @staticmethod + def _is_past_global_deadline(created_at: datetime) -> bool: + global_timeout_seconds = dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS + if global_timeout_seconds <= 0: + return False + global_deadline = ensure_naive_utc(created_at) + timedelta(seconds=global_timeout_seconds) + return global_deadline <= naive_utc_now() + + @staticmethod + def render_form_content_with_outputs( + form_content: str, + outputs: Mapping[str, Segment], + field_names: Sequence[str], + form_inputs: Sequence[FormInputConfig] | None = None, + ) -> str: + """Replace {{#$output.xxx#}} placeholders with submitted values. + + Text inputs render their submitted value directly. File inputs render as + stable placeholders so the final content stays readable and does not + inline transport metadata. + + Returns: + the interplated form content + """ + inputs_by_name: dict[str, FormInputConfig] = {} + if form_inputs is not None: + inputs_by_name = {form_input.output_variable_name: form_input for form_input in form_inputs} + + rendered_content = form_content + for field_name in field_names: + placeholder = "{{#$output." + field_name + "#}}" + replacement = DifyHITLCallback._render_output_placeholder_value( + value=outputs.get(field_name), + form_input=inputs_by_name.get(field_name), + ) + rendered_content = rendered_content.replace(placeholder, replacement) + return rendered_content + + @staticmethod + def _render_output_placeholder_value( + *, + value: Any, + form_input: FormInputConfig | None, + ) -> str: + if isinstance(value, Segment): + value = value.to_object() + + if value is None: + return "" + + if isinstance(form_input, FileInputConfig): + return "[file]" + + if isinstance(form_input, FileListInputConfig): + file_count = 0 + if isinstance(value, Sequence) and not isinstance(value, str | bytes): + file_count = len(value) + return f"[{file_count} files]" + + if isinstance(form_input, ParagraphInputConfig | SelectInputConfig): + return str(value) + + if isinstance(value, dict | list): + return json.dumps(value, ensure_ascii=False) + + return str(value) + + def _restore_submitted_data( + self, + *, + submitted_data: Mapping[str, Any], + ) -> dict[str, Segment]: + """Reconstruct graphon runtime values from validated submission payloads.""" + restored_data: dict[str, Segment] = {} + inputs_by_name = {form_input.output_variable_name: form_input for form_input in self._node_data.inputs} + + for name, value in submitted_data.items(): + form_input = inputs_by_name.get(name) + if form_input is None: + logger.error("unexpected form data in submitted data, key=%s", name) + continue + restored_data[name] = build_segment(self._restore_submitted_value(input_config=form_input, value=value)) + + return restored_data + + def _restore_submitted_value( + self, + *, + input_config: FormInputConfig, + value: Any, + ) -> Any: + if isinstance(input_config, FileInputConfig): + if not isinstance(value, Mapping): + raise ValueError( + "HumanInput file submission must be persisted as a mapping, " + f"output_variable_name={input_config.output_variable_name}" + ) + return self._build_file_reference(mapping=value) + + if isinstance(input_config, FileListInputConfig): + if not isinstance(value, list): + raise ValueError( + "HumanInput file-list submission must be persisted as a list, " + f"output_variable_name={input_config.output_variable_name}" + ) + if any(not isinstance(item, Mapping) for item in value): + raise ValueError( + "HumanInput file-list submission must contain mappings, " + f"output_variable_name={input_config.output_variable_name}" + ) + return [self._build_file_reference(mapping=item) for item in value] + + return value + + def _build_file_reference(self, *, mapping: Mapping[str, Any]) -> Any: + if self._file_reference_factory is None: + raise ValueError("file_reference_factory is required to restore file submissions") + return self._file_reference_factory.build_from_mapping(mapping=mapping) + + @classmethod + def _build_special_outputs( + cls, + *, + action_id: str, + action_value: str, + rendered_content: str, + ) -> dict[str, Segment]: + return { + cls._OUTPUT_FIELD_ACTION_ID: build_segment(action_id), + cls._OUTPUT_FIELD_ACTION_VALUE: build_segment(action_value), + cls._OUTPUT_FIELD_RENDERED_CONTENT: build_segment(rendered_content), + } diff --git a/api/core/workflow/nodes/human_input/entities.py b/api/core/workflow/nodes/human_input/entities.py new file mode 100644 index 00000000000..8fd983102c5 --- /dev/null +++ b/api/core/workflow/nodes/human_input/entities.py @@ -0,0 +1,374 @@ +"""Dify-owned Human Input entities. + +Graphon v0.6.0 keeps only the minimal HITL callback contract. Dify owns the +workflow-facing form schema, validation rules, and compatibility payload shapes +that used to live in graphon. +""" + +import abc +import re +from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta +from typing import Annotated, Any, Literal, Self, assert_never, override + +from pydantic import BaseModel, Field, NonNegativeInt, field_validator, model_validator + +from graphon.entities.base_node_data import BaseNodeData +from graphon.enums import BuiltinNodeTypes, NodeType +from graphon.file.enums import FileTransferMethod, FileType +from graphon.nodes.base.variable_template_parser import VariableTemplateParser +from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool +from graphon.variables.consts import SELECTORS_LENGTH +from graphon.variables.segments import Segment + +from . import _exc as exc +from .enums import ButtonStyle, FormInputType, TimeoutUnit, ValueSourceType + +_OUTPUT_VARIABLE_PATTERN = re.compile( + r"\{\{#\$output\.(?P[a-zA-Z_][a-zA-Z0-9_]{0,29})#\}\}", +) + + +class StringSource(BaseModel): + """Default configuration for form inputs.""" + + # NOTE: Ideally, a discriminated union would be used to model + # FormInputDefault. However, the UI requires preserving the previous + # value when switching between `VARIABLE` and `CONSTANT` types. This + # necessitates retaining all fields, making a discriminated union unsuitable. + + # NOTE: This class is renamed from FormInputDefault. + + type: ValueSourceType + + # The selector of default variable, used when `type` is `VARIABLE`. + selector: Sequence[str] = Field(default_factory=tuple) + + # Constant defaults are stored as strings because current form inputs are + # text-based (`TEXT_INPUT` and `PARAGRAPH`). + value: str = "" + + @model_validator(mode="after") + def _validate_selector(self) -> Self: + if self.type == ValueSourceType.CONSTANT: + return self + if len(self.selector) < SELECTORS_LENGTH: + msg = f"the length of selector should be at least {SELECTORS_LENGTH}, selector={self.selector}" + raise ValueError(msg) + return self + + +class StringListSource(BaseModel): + type: ValueSourceType + + # The selector of default variable, used when `type` is `VARIABLE`. + selector: Sequence[str] = Field(default_factory=tuple) + + # The value of the default, used when `type` is `CONSTANT`. + value: list[str] = Field(default_factory=list) + + +class BaseInputConfig(BaseModel): + """BaseInputConfig is the base class for all input field definitions. + One input corresponds to one output variable during form submission. + """ + + output_variable_name: str + + @abc.abstractmethod + def extract_variable_selectors(self) -> Sequence[Sequence[str]]: + """`extract_variable_selectors` extracts variable selectors + used by this input field. + """ + + @abc.abstractmethod + def resolve_default_value(self, pool: ReadOnlyVariablePool) -> Segment | None: + """`resolve_default_value` resolves the default value for form submission. + + If the form input does not specify a default value, or the default value does + not depend on the runtime variable, this method should return `None`. + """ + + +class ParagraphInputConfig(BaseInputConfig): + """Form input definition.""" + + # NOTE: This class is renamed from FormInput. + type: Literal[FormInputType.PARAGRAPH] = FormInputType.PARAGRAPH + default: StringSource | None = None + + @override + def extract_variable_selectors(self) -> Sequence[Sequence[str]]: + default = self.default + if default is None: + return [] + if default.type == ValueSourceType.CONSTANT: + return [] + return [default.selector] + + @override + def resolve_default_value(self, pool: ReadOnlyVariablePool) -> Segment | None: + default = self.default + if default is None: + return None + + if default.type == ValueSourceType.CONSTANT: + return None + + return pool.get(default.selector) + + +class SelectInputConfig(BaseInputConfig): + type: Literal[FormInputType.SELECT] = FormInputType.SELECT + option_source: StringListSource + + @override + def extract_variable_selectors(self) -> Sequence[Sequence[NodeType]]: + if self.option_source.type == ValueSourceType.CONSTANT: + return [] + return [self.option_source.selector] + + @override + def resolve_default_value(self, pool: ReadOnlyVariablePool) -> Segment | None: + _ = pool + return None + + +_ALLOWED_TRANSFER_METHOD = frozenset( + [ + FileTransferMethod.LOCAL_FILE, + FileTransferMethod.REMOTE_URL, + ] +) + + +class _FileInputCommonConfig(BaseModel): + allowed_file_types: Sequence[FileType] = Field(default_factory=list[FileType]) + allowed_file_extensions: Sequence[str] = Field(default_factory=list) + allowed_file_upload_methods: Sequence[FileTransferMethod] = Field(default_factory=list[FileTransferMethod]) + + @field_validator("allowed_file_upload_methods", mode="after") + @classmethod + def _validate_upload_methods(cls, transfer_methods: Sequence[FileTransferMethod]) -> Sequence[FileTransferMethod]: + validated_values: list[FileTransferMethod] = [] + for value in transfer_methods: + if value not in _ALLOWED_TRANSFER_METHOD: + raise exc.InvalidTransferMethodError(value) + validated_values.append(value) + + return validated_values + + @model_validator(mode="after") + def _validate_extensions(self) -> Self: + if FileType.CUSTOM not in self.allowed_file_types: + return self + if not self.allowed_file_extensions: + raise exc.ExtensionsNotSetErrorValueError + return self + + +class FileInputConfig(_FileInputCommonConfig, BaseInputConfig): + type: Literal[FormInputType.FILE] = FormInputType.FILE + + @override + def extract_variable_selectors(self) -> Sequence[Sequence[NodeType]]: + return [] + + @override + def resolve_default_value(self, pool: ReadOnlyVariablePool) -> Segment | None: + _ = pool + return None + + +class FileListInputConfig(_FileInputCommonConfig, BaseInputConfig): + type: Literal[FormInputType.FILE_LIST] = FormInputType.FILE_LIST + number_limits: NonNegativeInt = 0 + + @override + def extract_variable_selectors(self) -> Sequence[Sequence[NodeType]]: + return [] + + @override + def resolve_default_value(self, pool: ReadOnlyVariablePool) -> Segment | None: + _ = pool + return None + + +type FormInputConfig = Annotated[ + ParagraphInputConfig | SelectInputConfig | FileInputConfig | FileListInputConfig, + Field(discriminator="type"), +] + + +_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class UserActionConfig(BaseModel): + """User action configuration.""" + + # id is the identifier for this action. + # It also serves as the identifiers of output handle. + # + # The id must be a valid identifier (satisfy the _IDENTIFIER_PATTERN above.) + id: str = Field(max_length=20) + title: str = Field(max_length=100) + button_style: ButtonStyle = ButtonStyle.DEFAULT + + @field_validator("id") + @classmethod + def _validate_id(cls, value: str) -> str: + if not _IDENTIFIER_PATTERN.match(value): + msg = ( + f"'{value}' is not a valid identifier. It must start with " + f"a letter or underscore, and contain only letters, " + f"numbers, or underscores." + ) + raise ValueError(msg) + return value + + +class HumanInputNodeData(BaseNodeData): + """Human Input node data.""" + + type: NodeType = BuiltinNodeTypes.HUMAN_INPUT + form_content: str = "" + inputs: list[FormInputConfig] = Field(default_factory=list[FormInputConfig]) + user_actions: list[UserActionConfig] = Field(default_factory=list[UserActionConfig]) + timeout: int = 36 + timeout_unit: TimeoutUnit = TimeoutUnit.HOUR + + @field_validator("inputs") + @classmethod + def _validate_inputs(cls, inputs: list[FormInputConfig]) -> list[FormInputConfig]: + seen_names: set[str] = set() + for form_input in inputs: + name = form_input.output_variable_name + if name in seen_names: + msg = f"duplicated output_variable_name '{name}' in inputs" + raise ValueError(msg) + seen_names.add(name) + return inputs + + @field_validator("user_actions") + @classmethod + def _validate_user_actions(cls, user_actions: list[UserActionConfig]) -> list[UserActionConfig]: + seen_ids: set[str] = set() + for action in user_actions: + action_id = action.id + if action_id in seen_ids: + msg = f"duplicated user action id '{action_id}'" + raise ValueError(msg) + seen_ids.add(action_id) + return user_actions + + def expiration_time(self, start_time: datetime) -> datetime: + match self.timeout_unit: + case TimeoutUnit.HOUR: + return start_time + timedelta(hours=self.timeout) + case TimeoutUnit.DAY: + return start_time + timedelta(days=self.timeout) + case _: + assert_never(self.timeout_unit) + + def outputs_field_names(self) -> Sequence[str]: + return [match.group("field_name") for match in _OUTPUT_VARIABLE_PATTERN.finditer(self.form_content)] + + def extract_variable_selector_to_variable_mapping( + self, + node_id: str, + ) -> Mapping[str, Sequence[str]]: + variable_mappings: dict[str, Sequence[str]] = {} + + def _add_variable_selectors(selectors: Sequence[Sequence[str]]) -> None: + for selector in selectors: + if len(selector) < SELECTORS_LENGTH: + continue + qualified_variable_mapping_key = f"{node_id}.#{'.'.join(selector[:SELECTORS_LENGTH])}#" + variable_mappings[qualified_variable_mapping_key] = list( + selector[:SELECTORS_LENGTH], + ) + + form_template_parser = VariableTemplateParser(template=self.form_content) + _add_variable_selectors( + [selector.value_selector for selector in form_template_parser.extract_variable_selectors()] + ) + + for form_input in self.inputs: + selectors = form_input.extract_variable_selectors() + for selector in selectors: + value_key = ".".join(selector) + qualified_variable_mapping_key = f"{node_id}.#{value_key}#" + variable_mappings[qualified_variable_mapping_key] = selector + + return variable_mappings + + def find_action_text(self, action_id: str) -> str: + """Resolve action display text by id.""" + for action in self.user_actions: + if action.id == action_id: + return action.title + return action_id + + def must_resolve_action_value(self, action_id: str) -> str: + """Resolve the selected action's workflow-facing value by id. + + This method should only be called with action ids that have already been + validated against the node configuration. + + Returns: + The configured workflow-facing value for the selected action id. + + Raises: + AssertionError: If the action id is not present in the node config. + """ + for action in self.user_actions: + if action.id == action_id: + return action.title + msg = f"Invalid action: {action_id}" + raise AssertionError(msg) + + +class FormDefinition(BaseModel): + form_content: str + inputs: list[FormInputConfig] = Field(default_factory=list[FormInputConfig]) + user_actions: list[UserActionConfig] = Field(default_factory=list[UserActionConfig]) + rendered_content: str + expiration_time: datetime + + # this is used to store the resolved default values + default_values: dict[str, Any] = Field(default_factory=dict) + + # node_title records the title of the HumanInput node. + node_title: str | None = None + + # display_in_ui controls whether the form should be displayed in UI surfaces. + display_in_ui: bool | None = None + + +class HumanInputSubmissionValidationError(ValueError): + pass + + +def validate_human_input_submission( + *, + inputs: Sequence[FormInputConfig], + user_actions: Sequence[UserActionConfig], + selected_action_id: str, + form_data: Mapping[str, Any], +) -> None: + available_actions = {action.id for action in user_actions} + if selected_action_id not in available_actions: + msg = f"Invalid action: {selected_action_id}" + raise HumanInputSubmissionValidationError(msg) + + provided_inputs = set(form_data.keys()) + missing_inputs = [ + form_input.output_variable_name + for form_input in inputs + if form_input.output_variable_name not in provided_inputs + ] + + if missing_inputs: + missing_list = ", ".join(missing_inputs) + msg = f"Missing required inputs: {missing_list}" + raise HumanInputSubmissionValidationError(msg) diff --git a/api/core/workflow/nodes/human_input/enums.py b/api/core/workflow/nodes/human_input/enums.py new file mode 100644 index 00000000000..53a3bd0b964 --- /dev/null +++ b/api/core/workflow/nodes/human_input/enums.py @@ -0,0 +1,76 @@ +import enum + + +class HumanInputFormStatus(enum.StrEnum): + """Status of a human input form.""" + + # Awaiting submission from any recipient. Forms stay in this state until + # submitted or a timeout rule applies. + WAITING = enum.auto() + # Global timeout reached. The workflow run is stopped and will not resume. + # This is distinct from node-level timeout. + EXPIRED = enum.auto() + # Submitted by a recipient; form data is available and execution resumes + # along the selected action edge. + SUBMITTED = enum.auto() + # Node-level timeout reached. The human input node should emit a timeout + # event and the workflow should resume along the timeout edge. + TIMEOUT = enum.auto() + + +class HumanInputFormKind(enum.StrEnum): + """Kind of a human input form.""" + + RUNTIME = enum.auto() # Form created during workflow execution. + DELIVERY_TEST = enum.auto() # Form created for delivery tests. + + +class ButtonStyle(enum.StrEnum): + """Button styles for user actions.""" + + PRIMARY = enum.auto() + DEFAULT = enum.auto() + ACCENT = enum.auto() + GHOST = enum.auto() + + +class TimeoutUnit(enum.StrEnum): + """Timeout unit for form expiration.""" + + HOUR = enum.auto() + DAY = enum.auto() + + +class FormInputType(enum.StrEnum): + """Form input types. + + Name for this enumeration are intentionally keep the same as those for + `VariableEntityType`. + """ + + # Both `TEXT_INPUT` and `PARAGRAPH` represent string input fields. + # The corresponding generated variable type is `SegmentType.STRING`. + PARAGRAPH = "paragraph" + + # A single-select input field (e.g., a dropdown or radio buttons). + # The corresponding generated variable type is `SegmentType.STRING`. + SELECT = "select" + + # A file input field that accepts a single file. + # The corresponding generated variable type is `SegmentType.FILE`. + FILE = "file" + + # A file input field that accepts zero or more files. + # The corresponding generated variable type is `SegmentType.ARRAY_FILE`. + FILE_LIST = "file-list" + + +class ValueSourceType(enum.StrEnum): + """ValueSourceType records whether the value comes from a static setting + in form definiton, or a variable while the workflow is running. + """ + + # `VARIABLE` means that the value comes from a variable in workflow execution + VARIABLE = enum.auto() + # `CONSTANT` measn that the value comes from a static setting in form definition. + CONSTANT = enum.auto() diff --git a/api/core/workflow/nodes/human_input/pause_reason.py b/api/core/workflow/nodes/human_input/pause_reason.py new file mode 100644 index 00000000000..934219392f0 --- /dev/null +++ b/api/core/workflow/nodes/human_input/pause_reason.py @@ -0,0 +1,49 @@ +from collections.abc import Mapping +from enum import StrEnum +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from graphon.entities.pause_reason import PauseReasonType, SchedulingPause + +from .entities import FormInputConfig, UserActionConfig + + +class DifyHITLEventType(StrEnum): + """Dify HITL event type. + only used for discriminated union tag. + + """ + + # Ideally this should be a string constaint. However, we cannot put + # string constant into Literal type cosntructor. We have to warp it as a + # string enumeration. + HUMAN_INPUT_REQUIRED = PauseReasonType.LEGACY_HUMAN_INPUT_REQUIRED.value + + +class HumanInputRequired(BaseModel): + TYPE: Literal[DifyHITLEventType.HUMAN_INPUT_REQUIRED] = DifyHITLEventType.HUMAN_INPUT_REQUIRED + form_id: str + form_content: str + inputs: list[FormInputConfig] = Field(default_factory=list[FormInputConfig]) + actions: list[UserActionConfig] = Field(default_factory=list[UserActionConfig]) + node_id: str + node_title: str + + # The `resolved_default_values` stores the resolved values of variable + # defaults. It's a mapping from `output_variable_name` to their + # resolved values. + # + # For example, the form contains an input with output variable name `name` + # and placeholder type `VARIABLE`, its selector is ["start", "name"]. + # When the HumanInputNode is executed, the corresponding value of + # variable `start.name` in the variable pool is `John`. + # Thus, the resolved value of the output variable `name` is `John`. The + # `resolved_default_values` is `{"name": "John"}`. + # + # Only form inputs with default value type `VARIABLE` will be resolved + # and stored in `resolved_default_values`. + resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) + + +type PauseReason = HumanInputRequired | SchedulingPause diff --git a/api/core/workflow/nodes/human_input/session_binding.py b/api/core/workflow/nodes/human_input/session_binding.py new file mode 100644 index 00000000000..1f71839ae0c --- /dev/null +++ b/api/core/workflow/nodes/human_input/session_binding.py @@ -0,0 +1,16 @@ +class SessionBinding: + """Translate between graphon session ids and Dify form ids. + + Phase 1 keeps the public graphon `session_id` contract while Dify continues + to persist and query `form_id`. The identity mapping lives here so later + phases can change the translation without scattering equality assumptions. + """ + + def issue_session_id_for_form(self, *, form_id: str) -> str: + return form_id + + def resolve_form_id_from_session_id(self, *, session_id: str) -> str: + return session_id + + +default_session_binding = SessionBinding() diff --git a/api/models/human_input.py b/api/models/human_input.py index b84579a4e09..7d35cf990c7 100644 --- a/api/models/human_input.py +++ b/api/models/human_input.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Mapped, mapped_column, relationship from core.workflow.human_input_adapter import DeliveryMethodType -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.helper import generate_string from .base import Base, DefaultFieldsMixin diff --git a/api/models/workflow.py b/api/models/workflow.py index bf494ad1f4b..bec5df45cbf 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -25,6 +25,13 @@ from typing_extensions import deprecated from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE from core.workflow.human_input_adapter import adapt_node_config_for_graph +from core.workflow.nodes.human_input.pause_reason import ( + HumanInputRequired, +) +from core.workflow.nodes.human_input.pause_reason import ( + PauseReason as DifyPauseReason, +) +from core.workflow.nodes.human_input.session_binding import default_session_binding from core.workflow.variable_prefixes import ( CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID, @@ -32,7 +39,8 @@ from core.workflow.variable_prefixes import ( from extensions.ext_storage import Storage from factories.variable_factory import TypeMismatchError, build_segment_with_type from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter -from graphon.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType, SchedulingPause +from graphon.entities.pause_reason import HitlRequired, PauseReasonType, SchedulingPause +from graphon.entities.pause_reason import PauseReason as GraphonPauseReason from graphon.enums import ( BuiltinNodeTypes, NodeType, @@ -2106,7 +2114,7 @@ class WorkflowPauseReason(DefaultFieldsDCMixin, TypeBase): type_: Mapped[PauseReasonType] = mapped_column(EnumText(PauseReasonType), nullable=False) - # form_id is not empty if and if only type_ == PauseReasonType.HUMAN_INPUT_REQUIRED + # form_id is not empty if and only if type_ is one of the Human Input pause variants. # form_id: Mapped[str] = mapped_column( String(36), @@ -2144,12 +2152,24 @@ class WorkflowPauseReason(DefaultFieldsDCMixin, TypeBase): ) @classmethod - def from_entity(cls, *, pause_id: str, pause_reason: PauseReason) -> "WorkflowPauseReason": + def from_entity( + cls, + *, + pause_id: str, + pause_reason: GraphonPauseReason | DifyPauseReason, + ) -> "WorkflowPauseReason": match pause_reason: + case HitlRequired(): + return cls( + pause_id=pause_id, + type_=PauseReasonType.HITL_REQUIRED, + form_id=default_session_binding.resolve_form_id_from_session_id(session_id=pause_reason.session_id), + node_id=pause_reason.node_id, + ) case HumanInputRequired(): return cls( pause_id=pause_id, - type_=PauseReasonType.HUMAN_INPUT_REQUIRED, + type_=PauseReasonType.HITL_REQUIRED, form_id=pause_reason.form_id, node_id=pause_reason.node_id, ) @@ -2158,14 +2178,20 @@ class WorkflowPauseReason(DefaultFieldsDCMixin, TypeBase): case _: raise AssertionError(f"Unknown pause reason type: {pause_reason}") - def to_entity(self) -> PauseReason: - if self.type_ == PauseReasonType.HUMAN_INPUT_REQUIRED: + def to_entity(self) -> DifyPauseReason | GraphonPauseReason: + if self.type_ == PauseReasonType.LEGACY_HUMAN_INPUT_REQUIRED: return HumanInputRequired( form_id=self.form_id, form_content="", node_id=self.node_id, node_title="", ) + elif self.type_ == PauseReasonType.HITL_REQUIRED: + return HitlRequired( + session_id=default_session_binding.issue_session_id_for_form(form_id=self.form_id), + node_id=self.node_id, + node_title="", + ) elif self.type_ == PauseReasonType.SCHEDULED_PAUSE: return SchedulingPause(message=self.message) else: diff --git a/api/pyproject.toml b/api/pyproject.toml index 10866a53b5a..9978dad738f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "resend>=2.27.0,<3.0.0", # Emerging: newer and fast-moving, use compatible pins "fastopenapi[flask]==0.7.0", - "graphon==0.5.3", + "graphon==0.6.0", "httpx-sse==0.4.3", "json-repair==0.59.4", ] diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py index bc30e980619..b3225794db9 100644 --- a/api/repositories/api_workflow_run_repository.py +++ b/api/repositories/api_workflow_run_repository.py @@ -42,7 +42,8 @@ from typing import Protocol, TypedDict from sqlalchemy.orm import Session from core.repositories.factory import WorkflowExecutionRepository -from graphon.entities.pause_reason import PauseReason +from core.workflow.nodes.human_input.pause_reason import PauseReason as DifyPauseReason +from graphon.entities.pause_reason import PauseReason as GraphonPauseReason from graphon.enums import WorkflowType from libs.infinite_scroll_pagination import InfiniteScrollPagination from models.enums import WorkflowRunTriggeredFrom @@ -499,7 +500,7 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): workflow_run_id: str, state_owner_user_id: str, state: str, - pause_reasons: Sequence[PauseReason], + pause_reasons: Sequence[GraphonPauseReason | DifyPauseReason], ) -> WorkflowPauseEntity: """ Create a new workflow pause state. diff --git a/api/repositories/entities/workflow_pause.py b/api/repositories/entities/workflow_pause.py index 4d9b680e5c9..85a716aff76 100644 --- a/api/repositories/entities/workflow_pause.py +++ b/api/repositories/entities/workflow_pause.py @@ -10,7 +10,7 @@ from collections.abc import Sequence from datetime import datetime from typing import Protocol -from graphon.entities.pause_reason import PauseReason +from core.workflow.nodes.human_input.pause_reason import PauseReason as DifyPauseReason class WorkflowPauseEntity(Protocol): @@ -65,7 +65,7 @@ class WorkflowPauseEntity(Protocol): """`paused_at` returns the creation time of the pause.""" ... - def get_pause_reasons(self) -> Sequence[PauseReason]: + def get_pause_reasons(self) -> Sequence[DifyPauseReason]: """ Retrieve detailed reasons for this pause. diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index 2394377c9d4..ebdb9916540 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -33,10 +33,24 @@ from sqlalchemy import and_, delete, func, null, or_, select, tuple_ from sqlalchemy.engine import CursorResult from sqlalchemy.orm import Session, selectinload, sessionmaker +from core.workflow.nodes.human_input.entities import FormDefinition +from core.workflow.nodes.human_input.pause_reason import ( + HumanInputRequired, +) +from core.workflow.nodes.human_input.pause_reason import ( + PauseReason as DifyPauseReason, +) +from core.workflow.nodes.human_input.session_binding import default_session_binding from extensions.ext_storage import storage -from graphon.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType, SchedulingPause +from graphon.entities.pause_reason import ( + HitlRequired, + PauseReasonType, + SchedulingPause, +) +from graphon.entities.pause_reason import ( + PauseReason as GraphonPauseReason, +) from graphon.enums import WorkflowExecutionStatus, WorkflowType -from graphon.nodes.human_input.entities import FormDefinition from libs.datetime_utils import naive_utc_now from libs.helper import convert_datetime_to_date from libs.infinite_scroll_pagination import InfiniteScrollPagination @@ -59,6 +73,7 @@ from repositories.types import ( from services.retention.workflow_run.tenant_prefix import tenant_prefix_condition logger = logging.getLogger(__name__) +_HITL_REASON_TYPES = frozenset({PauseReasonType.LEGACY_HUMAN_INPUT_REQUIRED, PauseReasonType.HITL_REQUIRED}) class _WorkflowRunError(Exception): @@ -123,7 +138,7 @@ def _build_human_input_required_reason( definition = None if definition is not None: - form_content = definition.form_content + form_content = form_model.rendered_content or definition.rendered_content or definition.form_content inputs = list(definition.inputs) actions = list(definition.user_actions) resolved_default_values = dict(definition.default_values) @@ -141,6 +156,13 @@ def _build_human_input_required_reason( return reason +def _to_dify_pause_reason(reason_model: WorkflowPauseReason) -> DifyPauseReason: + """Map persisted pause reasons onto the Dify-facing repository contract.""" + if reason_model.type_ in _HITL_REASON_TYPES: + return _build_human_input_required_reason(reason_model, None) + return cast("DifyPauseReason", reason_model.to_entity()) + + class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): """ SQLAlchemy implementation of APIWorkflowRunRepository. @@ -947,7 +969,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): workflow_run_id: str, state_owner_user_id: str, state: str, - pause_reasons: Sequence[PauseReason], + pause_reasons: Sequence[GraphonPauseReason | DifyPauseReason], ) -> WorkflowPauseEntity: """ Create a new workflow pause state. @@ -1003,12 +1025,21 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): pause_reason_models = [] for reason in pause_reasons: match reason: - case HumanInputRequired(): - # TODO(QuantumGhost): record node_id for `WorkflowPauseReason` + case HitlRequired(): pause_reason_model = WorkflowPauseReason( pause_id=pause_model.id, - type_=reason.TYPE, + type_=PauseReasonType.HITL_REQUIRED, + form_id=default_session_binding.resolve_form_id_from_session_id( + session_id=reason.session_id + ), + node_id=reason.node_id, + ) + case HumanInputRequired(): + pause_reason_model = WorkflowPauseReason( + pause_id=pause_model.id, + type_=PauseReasonType.HITL_REQUIRED, form_id=reason.form_id, + node_id=reason.node_id, ) case SchedulingPause(): pause_reason_model = WorkflowPauseReason( @@ -1031,10 +1062,15 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): logger.info("Created workflow pause %s for workflow run %s", pause_model.id, workflow_run_id) + # NOTE(QuantumGhost): repository callers on the Dify side should only + # observe enriched Dify pause reasons. The Graphon-native reason is an + # input-only boundary concern while persisting the pause. + hydrated_pause_reasons = self._hydrate_pause_reasons(session, pause_reason_models) + return _PrivateWorkflowPauseEntity( pause_model=pause_model, reason_models=pause_reason_models, - pause_reasons=pause_reasons, + pause_reasons=hydrated_pause_reasons, ) def _get_reasons_by_pause_id(self, session: Session, pause_id: str): @@ -1046,11 +1082,9 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): self, session: Session, pause_reason_models: Sequence[WorkflowPauseReason], - ) -> list[PauseReason]: + ) -> list[DifyPauseReason]: form_ids = [ - reason.form_id - for reason in pause_reason_models - if reason.type_ == PauseReasonType.HUMAN_INPUT_REQUIRED and reason.form_id + reason.form_id for reason in pause_reason_models if reason.type_ in _HITL_REASON_TYPES and reason.form_id ] form_models: dict[str, HumanInputForm] = {} if form_ids: @@ -1063,9 +1097,9 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): for recipient in session.scalars(recipient_stmt).all(): recipients_by_form_id.setdefault(recipient.form_id, []).append(recipient) - pause_reasons: list[PauseReason] = [] + pause_reasons: list[DifyPauseReason] = [] for reason in pause_reason_models: - if reason.type_ == PauseReasonType.HUMAN_INPUT_REQUIRED: + if reason.type_ in _HITL_REASON_TYPES: form_model = form_models.get(reason.form_id) pause_reasons.append( _build_human_input_required_reason( @@ -1075,7 +1109,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): ) ) else: - pause_reasons.append(reason.to_entity()) + pause_reasons.append(_to_dify_pause_reason(reason)) return pause_reasons @override @@ -1541,7 +1575,7 @@ class _PrivateWorkflowPauseEntity(WorkflowPauseEntity): *, pause_model: WorkflowPause, reason_models: Sequence[WorkflowPauseReason], - pause_reasons: Sequence[PauseReason] | None = None, + pause_reasons: Sequence[DifyPauseReason] | None = None, human_input_form: Sequence = (), ) -> None: self._pause_model = pause_model @@ -1587,10 +1621,10 @@ class _PrivateWorkflowPauseEntity(WorkflowPauseEntity): return self._pause_model.resumed_at @override - def get_pause_reasons(self) -> Sequence[PauseReason]: + def get_pause_reasons(self) -> Sequence[DifyPauseReason]: if self._pause_reasons is not None: - return list(self._pause_reasons) # type: ignore - return [reason.to_entity() for reason in self._reason_models] # type: ignore + return list(self._pause_reasons) + return [_to_dify_pause_reason(reason) for reason in self._reason_models] @property @override diff --git a/api/repositories/sqlalchemy_execution_extra_content_repository.py b/api/repositories/sqlalchemy_execution_extra_content_repository.py index cfac363b19a..f20ee7f3f58 100644 --- a/api/repositories/sqlalchemy_execution_extra_content_repository.py +++ b/api/repositories/sqlalchemy_execution_extra_content_repository.py @@ -18,9 +18,9 @@ from core.entities.execution_extra_content import ( from core.entities.execution_extra_content import ( HumanInputContent as HumanInputContentDomainModel, ) -from graphon.nodes.human_input.entities import FormDefinition -from graphon.nodes.human_input.enums import HumanInputFormStatus -from graphon.nodes.human_input.human_input_node import HumanInputNode +from core.workflow.nodes.human_input.callback import DifyHITLCallback +from core.workflow.nodes.human_input.entities import FormDefinition +from core.workflow.nodes.human_input.enums import HumanInputFormStatus from models.execution_extra_content import ( ExecutionExtraContent as ExecutionExtraContentModel, ) @@ -166,7 +166,7 @@ class SQLAlchemyExecutionExtraContentRepository(ExecutionExtraContentRepository) logger.warning("Failed to load submitted data for HumanInputContent(id=%s)", model.id) return None - rendered_content = HumanInputNode.render_form_content_with_outputs( + rendered_content = DifyHITLCallback.render_form_content_with_outputs( form.rendered_content, submitted_data, _extract_output_field_names(form_definition.form_content), diff --git a/api/services/human_input_file_upload_service.py b/api/services/human_input_file_upload_service.py index fe8912a2616..3f502c2a9e7 100644 --- a/api/services/human_input_file_upload_service.py +++ b/api/services/human_input_file_upload_service.py @@ -8,7 +8,7 @@ from sqlalchemy import Engine, select from sqlalchemy.orm import Session, selectinload, sessionmaker from configs import dify_config -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.datetime_utils import ensure_naive_utc, naive_utc_now from models.account import Account, Tenant from models.enums import CreatorUserRole diff --git a/api/services/human_input_service.py b/api/services/human_input_service.py index cf6645d9358..66a1a88c524 100644 --- a/api/services/human_input_service.py +++ b/api/services/human_input_service.py @@ -15,9 +15,7 @@ from core.repositories.human_input_repository import ( HumanInputFormSubmissionRepository, ) from core.workflow.human_input_policy import resolve_variable_select_input_options -from factories.file_factory import build_from_mapping, build_from_mappings -from graphon.file import FileUploadConfig -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( FileInputConfig, FileListInputConfig, FormDefinition, @@ -26,10 +24,12 @@ from graphon.nodes.human_input.entities import ( SelectInputConfig, UserActionConfig, ) -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( validate_human_input_submission as graphon_validate_human_input_submission, ) -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from factories.file_factory import build_from_mapping, build_from_mappings +from graphon.file import FileUploadConfig from graphon.runtime import GraphRuntimeState from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool from libs.datetime_utils import ensure_naive_utc, naive_utc_now diff --git a/api/services/workflow_event_snapshot_service.py b/api/services/workflow_event_snapshot_service.py index c693f6318bd..d9365f861b5 100644 --- a/api/services/workflow_event_snapshot_service.py +++ b/api/services/workflow_event_snapshot_service.py @@ -33,8 +33,11 @@ from core.workflow.human_input_policy import ( resolve_human_input_pause_reason_inputs, resolve_variable_select_input_options, ) +from core.workflow.nodes.human_input.pause_reason import ( + DifyHITLEventType, + HumanInputRequired, +) from graphon.entities import WorkflowStartReason -from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from graphon.runtime import GraphRuntimeState from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool @@ -494,7 +497,7 @@ def _build_pause_event( human_input_form_ids = [ form_id for reason in reasons - if reason.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED + if reason.get("TYPE") == DifyHITLEventType.HUMAN_INPUT_REQUIRED for form_id in [reason.get("form_id")] if isinstance(form_id, str) ] diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index c3327f5787d..048b25c6bf9 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -3,6 +3,7 @@ import logging import time import uuid from collections.abc import Callable, Generator, Mapping, Sequence +from dataclasses import dataclass from typing import Any, cast from sqlalchemy import exists, select @@ -11,7 +12,6 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker from configs import dify_config from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager -from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context from core.app.file_access import DatabaseFileAccessController from core.entities import PluginCredentialType from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly, create_plugin_provider_manager @@ -25,15 +25,20 @@ from core.workflow.human_input_adapter import ( ) from core.workflow.node_factory import ( LATEST_VERSION, - DifyGraphInitContext, get_node_type_classes_mapping, is_start_node_type, ) from core.workflow.node_runtime import ( - DifyFileReferenceFactory, - DifyHumanInputNodeRuntime, apply_dify_debug_email_recipient, ) +from core.workflow.nodes.human_input.callback import ( + DifyHITLCallback, + render_form_content_before_submission, + resolve_default_values, +) +from core.workflow.nodes.human_input.entities import FormInputConfig, HumanInputNodeData +from core.workflow.nodes.human_input.enums import HumanInputFormKind +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import build_bootstrap_variables, build_system_variables, default_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool from core.workflow.workflow_entry import WorkflowEntry @@ -45,7 +50,6 @@ from extensions.ext_storage import storage from factories.file_factory import build_from_mapping, build_from_mappings from graphon.entities import WorkflowNodeExecution from graphon.entities.graph_config import NodeConfigDict -from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import ( ErrorStrategy, NodeType, @@ -59,11 +63,8 @@ from graphon.node_events import NodeRunResult from graphon.nodes import BuiltinNodeTypes from graphon.nodes.base.node import Node from graphon.nodes.http_request import HTTP_REQUEST_CONFIG_FILTER_KEY, build_http_request_config -from graphon.nodes.human_input.entities import HumanInputNodeData -from graphon.nodes.human_input.enums import HumanInputFormKind -from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.start.entities import StartNodeData -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import VariablePool from graphon.variable_loader import load_into_variable_pool from graphon.variables import VariableBase from graphon.variables.input_entities import VariableEntityType @@ -82,6 +83,51 @@ from services.errors.app import ( WorkflowHashNotEqualError, WorkflowNotFoundError, ) + + +@dataclass(frozen=True) +class _DebugHumanInputNode: + node_id: str + title: str + node_data: HumanInputNodeData + variable_pool: VariablePool + + def render_form_content_before_submission(self) -> str: + return render_form_content_before_submission( + self.node_data, + variable_pool=self.variable_pool, + ) + + def resolve_default_values(self) -> Mapping[str, Any]: + return resolve_default_values( + self.node_data, + variable_pool=self.variable_pool, + ) + + def render_form_content_with_outputs( + self, + form_content: str, + outputs: Mapping[str, Any], + field_names: Sequence[str], + form_inputs: Sequence[FormInputConfig] | None = None, + ) -> str: + return DifyHITLCallback.render_form_content_with_outputs( + form_content=form_content, + outputs=outputs, # type: ignore[arg-type] + field_names=field_names, + form_inputs=form_inputs, + ) + + @property + def workflow_execution_id(self) -> str: + return "debug-human-input" + + @property + def node_title(self) -> str: + return self.title + + +HumanInputNode = _DebugHumanInputNode from services.human_input_service import HumanInputService from services.workflow.workflow_converter import WorkflowConverter from services.workflow_ref_service import WorkflowRef @@ -1276,35 +1322,15 @@ class WorkflowService: account: Account, node_config: NodeConfigDict, variable_pool: VariablePool, - ) -> HumanInputNode: - run_context = build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=workflow.app_id, - user_id=account.id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ) - graph_init_context = DifyGraphInitContext( - workflow_id=workflow.id, - graph_config=workflow.graph_dict, - run_context=run_context, - call_depth=0, - ) - graph_init_params = graph_init_context.to_graph_init_params() - graph_runtime_state = GraphRuntimeState( - variable_pool=variable_pool, - start_at=time.perf_counter(), - ) - node_data = HumanInputNode.validate_node_data(adapt_human_input_node_data_for_graph(node_config["data"])) - node = HumanInputNode( + ) -> _DebugHumanInputNode: + _ = workflow, account + node_data = HumanInputNodeData.model_validate(adapt_human_input_node_data_for_graph(node_config["data"])) + return HumanInputNode( node_id=node_config["id"], - data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, - runtime=DifyHumanInputNodeRuntime(run_context), - file_reference_factory=DifyFileReferenceFactory(run_context), + title=node_data.title, + node_data=node_data, + variable_pool=variable_pool, ) - return node def _build_human_input_variable_pool( self, @@ -1334,10 +1360,10 @@ class WorkflowService: tenant_id=app_model.tenant_id, user_id=user_id, ) - variable_mapping = HumanInputNode.extract_variable_selector_to_variable_mapping( - graph_config=workflow.graph_dict, - config=node_config, + human_input_node_data = HumanInputNodeData.model_validate( + adapt_human_input_node_data_for_graph(node_config["data"]) ) + variable_mapping = human_input_node_data.extract_variable_selector_to_variable_mapping(node_config["id"]) normalized_user_inputs: dict[str, Any] = dict(manual_inputs) load_into_variable_pool( @@ -1586,7 +1612,7 @@ class WorkflowService: Raises: ValueError: If the node data format is invalid """ - from graphon.nodes.human_input.entities import HumanInputNodeData + from core.workflow.nodes.human_input.entities import HumanInputNodeData try: HumanInputNodeData.model_validate(adapt_human_input_node_data_for_graph(node_data)) diff --git a/api/tasks/human_input_timeout_tasks.py b/api/tasks/human_input_timeout_tasks.py index f670d5a3382..7325602e7d2 100644 --- a/api/tasks/human_input_timeout_tasks.py +++ b/api/tasks/human_input_timeout_tasks.py @@ -7,10 +7,10 @@ from sqlalchemy.orm import sessionmaker from configs import dify_config from core.repositories.human_input_repository import HumanInputFormSubmissionRepository +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from extensions.ext_database import db from extensions.ext_storage import storage from graphon.enums import WorkflowExecutionStatus -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.datetime_utils import ensure_naive_utc, naive_utc_now from models.human_input import HumanInputForm from models.workflow import WorkflowPause, WorkflowRun diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py index 3554ea48b9a..4ec706c1f10 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py @@ -14,11 +14,16 @@ from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from core.workflow.human_input_adapter import DeliveryMethodType +from core.workflow.nodes.human_input.entities import ( + FormDefinition, + SelectInputConfig, + StringListSource, + UserActionConfig, +) +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.entities import WorkflowExecution -from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus -from graphon.nodes.human_input.entities import FormDefinition, SelectInputConfig, StringListSource, UserActionConfig -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom diff --git a/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py index 676833ce981..b3f1bd2f5c8 100644 --- a/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py +++ b/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py @@ -17,7 +17,7 @@ from core.workflow.human_input_adapter import ( MemberRecipient, WebAppDeliveryMethod, ) -from graphon.nodes.human_input.entities import FormDefinition, HumanInputNodeData, UserActionConfig +from core.workflow.nodes.human_input.entities import FormDefinition, HumanInputNodeData, UserActionConfig from models.account import ( Account, AccountStatus, diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py index 7efb78fb046..60f3996e2d0 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py @@ -13,7 +13,11 @@ from core.app.workflow.layers import PersistenceWorkflowInfo, WorkflowPersistenc from core.repositories.human_input_repository import HumanInputFormEntity, HumanInputFormRepository 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, DifyHumanInputNodeRuntime +from core.workflow.nodes.human_input.callback import ( + DifyHITLCallback, +) +from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserActionConfig +from core.workflow.nodes.human_input.enums import HumanInputFormStatus from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowType from graphon.graph import Graph @@ -21,8 +25,6 @@ from graphon.graph_engine import GraphEngine from graphon.graph_engine.command_channels import InMemoryChannel from graphon.nodes.end.end_node import EndNode from graphon.nodes.end.entities import EndNodeData -from graphon.nodes.human_input.entities import HumanInputNodeData, UserActionConfig -from graphon.nodes.human_input.enums import HumanInputFormStatus from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.start.entities import StartNodeData from graphon.nodes.start.start_node import StartNode @@ -115,14 +117,16 @@ def _build_graph( UserActionConfig(id="continue", title="Continue"), ], ) + hitl_callback = DifyHITLCallback( + form_repository=form_repository, + node_data=human_data, + ) human_node = HumanInputNode( node_id="human", data=human_data, graph_init_params=params, graph_runtime_state=runtime_state, - form_repository=form_repository, - file_reference_factory=DifyFileReferenceFactory(params.run_context), - runtime=DifyHumanInputNodeRuntime(params.run_context), + hitl_callback=hitl_callback, ) end_data = EndNodeData( diff --git a/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py b/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py index dc8b3827c6f..094ca6ca04f 100644 --- a/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py +++ b/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py @@ -5,7 +5,7 @@ from datetime import timedelta from decimal import Decimal from uuid import uuid4 -from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig +from core.workflow.nodes.human_input.entities import FormDefinition, UserActionConfig from libs.datetime_utils import naive_utc_now from models.account import Account, Tenant, TenantAccountJoin from models.enums import ConversationFromSource, InvokeFrom diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py index 3f3b3eae525..a11b19bf9b5 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -13,12 +13,13 @@ from sqlalchemy import Engine, delete, select from sqlalchemy.orm import Session, sessionmaker from core.workflow.human_input_adapter import DeliveryMethodType +from core.workflow.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig +from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormStatus +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from extensions.ext_storage import storage from graphon.entities import WorkflowExecution -from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType +from graphon.entities.pause_reason import HitlRequired, PauseReasonType from graphon.enums import WorkflowExecutionStatus -from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig -from graphon.nodes.human_input.enums import FormInputType, HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.human_input import ( @@ -387,6 +388,232 @@ class TestCreateWorkflowPause: pause_reasons=[], ) + def test_create_workflow_pause_writes_hitl_type_for_dify_human_input_reason( + self, + repository: DifyAPISQLAlchemyWorkflowRunRepository, + db_session_with_containers: Session, + test_scope: _TestScope, + ) -> None: + """Persist Dify human-input reasons using graphon HITL rows while preserving enriched reads.""" + + workflow_run = _create_workflow_run( + db_session_with_containers, + test_scope, + status=WorkflowExecutionStatus.RUNNING, + ) + expiration_time = naive_utc_now() + form_definition = FormDefinition( + form_content="content", + inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + user_actions=[UserActionConfig(id="approve", title="Approve")], + rendered_content="rendered", + expiration_time=expiration_time, + default_values={"name": "Alice"}, + node_title="Ask Name", + display_in_ui=True, + ) + form_model = HumanInputForm( + tenant_id=test_scope.tenant_id, + app_id=test_scope.app_id, + workflow_run_id=workflow_run.id, + node_id="node-1", + form_definition=form_definition.model_dump_json(), + rendered_content="rendered", + status=HumanInputFormStatus.WAITING, + expiration_time=expiration_time, + ) + db_session_with_containers.add(form_model) + db_session_with_containers.commit() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=test_scope.user_id, + state='{"test": "state"}', + pause_reasons=[ + HumanInputRequired( + form_id=form_model.id, + form_content="ignored-at-persistence-boundary", + inputs=[], + actions=[], + node_id="node-1", + node_title="Ask Name", + ) + ], + ) + + pause_model = db_session_with_containers.get(WorkflowPause, pause_entity.id) + assert pause_model is not None + test_scope.state_keys.add(pause_model.state_object_key) + + reason_models = db_session_with_containers.scalars( + select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_model.id) + ).all() + + assert len(reason_models) == 1 + assert reason_models[0].type_ == PauseReasonType.HITL_REQUIRED + assert reason_models[0].form_id == form_model.id + assert reason_models[0].node_id == "node-1" + + pause_reasons = pause_entity.get_pause_reasons() + + assert len(pause_reasons) == 1 + reason = pause_reasons[0] + assert isinstance(reason, HumanInputRequired) + assert reason.form_id == form_model.id + assert reason.node_id == "node-1" + assert reason.node_title == "Ask Name" + assert reason.form_content == "rendered" + assert reason.resolved_default_values == {"name": "Alice"} + + reloaded_pause = repository.get_workflow_pause(workflow_run.id) + + assert reloaded_pause is not None + reloaded_reasons = reloaded_pause.get_pause_reasons() + assert len(reloaded_reasons) == 1 + reloaded_reason = reloaded_reasons[0] + assert isinstance(reloaded_reason, HumanInputRequired) + assert reloaded_reason.form_id == form_model.id + assert reloaded_reason.node_id == "node-1" + assert reloaded_reason.node_title == "Ask Name" + + def test_create_workflow_pause_round_trips_graphon_hitl_reason( + self, + repository: DifyAPISQLAlchemyWorkflowRunRepository, + db_session_with_containers: Session, + test_scope: _TestScope, + ) -> None: + """Persist graphon HITL rows while keeping repository reads Dify-enriched.""" + + workflow_run = _create_workflow_run( + db_session_with_containers, + test_scope, + status=WorkflowExecutionStatus.RUNNING, + ) + expiration_time = naive_utc_now() + form_definition = FormDefinition( + form_content="content", + inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + user_actions=[UserActionConfig(id="approve", title="Approve")], + rendered_content="rendered", + expiration_time=expiration_time, + default_values={"name": "Alice"}, + node_title="Ask Name", + display_in_ui=True, + ) + form_model = HumanInputForm( + tenant_id=test_scope.tenant_id, + app_id=test_scope.app_id, + workflow_run_id=workflow_run.id, + node_id="node-1", + form_definition=form_definition.model_dump_json(), + rendered_content="rendered", + status=HumanInputFormStatus.WAITING, + expiration_time=expiration_time, + ) + db_session_with_containers.add(form_model) + db_session_with_containers.commit() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=test_scope.user_id, + state='{"test": "state"}', + pause_reasons=[ + HitlRequired( + session_id=form_model.id, + node_id="node-1", + node_title="Ask Name", + ) + ], + ) + + pause_model = db_session_with_containers.get(WorkflowPause, pause_entity.id) + assert pause_model is not None + test_scope.state_keys.add(pause_model.state_object_key) + + reason_models = db_session_with_containers.scalars( + select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_model.id) + ).all() + + assert len(reason_models) == 1 + assert reason_models[0].type_ == PauseReasonType.HITL_REQUIRED + raw_reason = reason_models[0].to_entity() + assert isinstance(raw_reason, HitlRequired) + assert raw_reason.TYPE == PauseReasonType.HITL_REQUIRED + assert raw_reason.session_id == form_model.id + assert raw_reason.node_id == "node-1" + + pause_reasons = pause_entity.get_pause_reasons() + assert len(pause_reasons) == 1 + reason = pause_reasons[0] + assert isinstance(reason, HumanInputRequired) + assert reason.form_id == form_model.id + assert reason.node_id == "node-1" + assert reason.node_title == "Ask Name" + + def test_get_workflow_pause_reads_legacy_human_input_reason( + self, + repository: DifyAPISQLAlchemyWorkflowRunRepository, + db_session_with_containers: Session, + test_scope: _TestScope, + ) -> None: + """Hydrate old legacy HITL rows into the unchanged Dify-facing payload.""" + + workflow_run = _create_workflow_run( + db_session_with_containers, + test_scope, + status=WorkflowExecutionStatus.PAUSED, + ) + expiration_time = naive_utc_now() + form_definition = FormDefinition( + form_content="content", + inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + user_actions=[UserActionConfig(id="approve", title="Approve")], + rendered_content="rendered", + expiration_time=expiration_time, + default_values={"name": "Alice"}, + node_title="Ask Name", + display_in_ui=True, + ) + form_model = HumanInputForm( + tenant_id=test_scope.tenant_id, + app_id=test_scope.app_id, + workflow_run_id=workflow_run.id, + node_id="node-1", + form_definition=form_definition.model_dump_json(), + rendered_content="rendered", + status=HumanInputFormStatus.WAITING, + expiration_time=expiration_time, + ) + pause_model = WorkflowPause( + workflow_id=test_scope.workflow_id, + workflow_run_id=workflow_run.id, + state_object_key=f"workflow-state-{uuid4()}.json", + ) + db_session_with_containers.add_all([form_model, pause_model]) + db_session_with_containers.flush() + reason_model = WorkflowPauseReason( + pause_id=pause_model.id, + type_=PauseReasonType.LEGACY_HUMAN_INPUT_REQUIRED, + form_id=form_model.id, + node_id="node-1", + ) + db_session_with_containers.add(reason_model) + db_session_with_containers.commit() + test_scope.state_keys.add(pause_model.state_object_key) + + pause_entity = repository.get_workflow_pause(workflow_run.id) + + assert pause_entity is not None + pause_reasons = pause_entity.get_pause_reasons() + assert len(pause_reasons) == 1 + reason = pause_reasons[0] + assert isinstance(reason, HumanInputRequired) + assert reason.form_id == form_model.id + assert reason.node_id == "node-1" + assert reason.node_title == "Ask Name" + assert reason.form_content == "rendered" + assert reason.resolved_default_values == {"name": "Alice"} + class TestResumeWorkflowPause: """Integration tests for resume_workflow_pause.""" @@ -715,7 +942,7 @@ class TestBuildHumanInputRequiredReason: reason_model = WorkflowPauseReason( pause_id=pause.id, - type_=PauseReasonType.HUMAN_INPUT_REQUIRED, + type_=PauseReasonType.HITL_REQUIRED, form_id=form_model.id, node_id="node-1", message="", @@ -738,7 +965,7 @@ class TestBuildHumanInputRequiredReason: assert isinstance(reason, HumanInputRequired) assert reason.node_title == "Ask Name" - assert reason.form_content == "content" + assert reason.form_content == "rendered" assert reason.inputs[0].output_variable_name == "name" assert reason.actions[0].id == "approve" assert reason.resolved_default_values == {"name": "Alice"} @@ -819,7 +1046,7 @@ class TestBuildHumanInputRequiredReason: reason_model = WorkflowPauseReason( pause_id=pause.id, - type_=PauseReasonType.HUMAN_INPUT_REQUIRED, + type_=PauseReasonType.HITL_REQUIRED, form_id=form_model.id, node_id="node-1", message="", diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py index 4ce6bb98c3b..4c8e51048a5 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py @@ -16,8 +16,8 @@ import pytest from sqlalchemy import Engine, delete, select from sqlalchemy.orm import Session, sessionmaker -from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig -from graphon.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input.entities import FormDefinition, UserActionConfig +from core.workflow.nodes.human_input.enums import HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.enums import ConversationFromSource, InvokeFrom diff --git a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py index 9241d5bcc95..c1188d3d0f9 100644 --- a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py +++ b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py @@ -16,9 +16,9 @@ from core.workflow.human_input_adapter import ( EmailRecipients, ExternalRecipient, ) +from core.workflow.nodes.human_input.entities import FileInputConfig, HumanInputNodeData +from core.workflow.nodes.human_input.enums import HumanInputFormKind from graphon.enums import BuiltinNodeTypes -from graphon.nodes.human_input.entities import FileInputConfig, HumanInputNodeData -from graphon.nodes.human_input.enums import HumanInputFormKind from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.human_input import HumanInputForm, HumanInputFormRecipient, HumanInputFormUploadFile from models.model import App, AppMode, UploadFile diff --git a/api/tests/test_containers_integration_tests/services/test_human_input_file_upload_service.py b/api/tests/test_containers_integration_tests/services/test_human_input_file_upload_service.py index ac9a19c4c48..d88d43fad0b 100644 --- a/api/tests/test_containers_integration_tests/services/test_human_input_file_upload_service.py +++ b/api/tests/test_containers_integration_tests/services/test_human_input_file_upload_service.py @@ -7,8 +7,8 @@ from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker import services.human_input_file_upload_service as service_module +from core.workflow.nodes.human_input.enums import HumanInputFormKind from extensions.ext_database import db -from graphon.nodes.human_input.enums import HumanInputFormKind from libs.datetime_utils import naive_utc_now from models.human_input import ( HumanInputForm, diff --git a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py index ce8b29c8407..4b5a0fb8bc6 100644 --- a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -12,10 +12,10 @@ 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.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper -from graphon.entities.pause_reason import HumanInputRequired +from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource, UserActionConfig +from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus -from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource, UserActionConfig -from graphon.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType from graphon.runtime import GraphRuntimeState, VariablePool from models.enums import CreatorUserRole from models.human_input import HumanInputForm diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py index a452bee9f81..068c29740db 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py @@ -18,9 +18,9 @@ from core.workflow.human_input_adapter import ( ExternalRecipient, MemberRecipient, ) +from core.workflow.nodes.human_input.entities import HumanInputNodeData from extensions.ext_storage import storage from graphon.enums import WorkflowExecutionStatus -from graphon.nodes.human_input.entities import HumanInputNodeData from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py index 4981971bc1b..502fa99fd75 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py @@ -10,9 +10,9 @@ from flask import Flask from controllers.common.errors import NotFoundError from controllers.console.app import workflow_run as workflow_run_module -from graphon.entities.pause_reason import HumanInputRequired +from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus -from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig from models.workflow import WorkflowRun diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py index 17f2ac3f17d..5ab1bd4ff5b 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -30,12 +30,12 @@ from core.app.entities.task_entities import ( ) from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from core.workflow.human_input_policy import FormDisposition, HumanInputSurface +from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig +from core.workflow.nodes.human_input.enums import FormInputType +from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType, HumanInputRequired from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason -from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus -from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig -from graphon.nodes.human_input.enums import FormInputType from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account from models.enums import CreatorUserRole @@ -115,7 +115,7 @@ def _build_advanced_chat_paused_blocking_response() -> AdvancedChatPausedBlockin paused_nodes=["node-1"], reasons=[ { - "type": PauseReasonType.HUMAN_INPUT_REQUIRED, + "type": DifyHITLEventType.HUMAN_INPUT_REQUIRED.value, "form_id": "form-1", "expiration_time": 100, } @@ -384,7 +384,7 @@ class TestHitlServiceApi: assert response["event"] == "workflow_paused" assert response["workflow_run_id"] == "run-1" assert response["answer"] == "partial" - assert response["data"]["reasons"][0]["type"] == PauseReasonType.HUMAN_INPUT_REQUIRED + assert response["data"]["reasons"][0]["type"] == DifyHITLEventType.HUMAN_INPUT_REQUIRED assert response["data"]["reasons"][0]["expiration_time"] == 100 assert "human_input_forms" not in response["data"] @@ -476,7 +476,7 @@ class TestHitlServiceApi: outputs={}, reasons=[ { - "type": PauseReasonType.HUMAN_INPUT_REQUIRED, + "type": DifyHITLEventType.HUMAN_INPUT_REQUIRED.value, "form_id": "form-1", "node_id": "node-1", "expiration_time": 123, diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index 88a7fce7055..50ce07cbe7e 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -15,8 +15,8 @@ from werkzeug.exceptions import Forbidden import controllers.web.human_input_form as human_input_module import controllers.web.site as site_module from controllers.web.error import WebFormRateLimitExceededError -from graphon.nodes.human_input.entities import ParagraphInputConfig, SelectInputConfig, StringListSource -from graphon.nodes.human_input.enums import ValueSourceType +from core.workflow.nodes.human_input.entities import ParagraphInputConfig, SelectInputConfig, StringListSource +from core.workflow.nodes.human_input.enums import ValueSourceType from models.human_input import RecipientType from services.feature_service import FeatureModel from services.human_input_service import FormExpiredError diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py index 6debeb4fdd0..57f232fe699 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py @@ -13,7 +13,7 @@ from core.app.entities.task_entities import ( NodeStartStreamResponse, PingStreamResponse, ) -from graphon.entities.pause_reason import PauseReasonType +from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus @@ -43,7 +43,7 @@ class TestAdvancedChatGenerateResponseConverter: metadata={"usage": {"total_tokens": 1}}, created_at=1, paused_nodes=["node-1"], - reasons=[{"type": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "form-1"}], + reasons=[{"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, "form_id": "form-1"}], status=WorkflowExecutionStatus.PAUSED, elapsed_time=0.1, total_tokens=0, diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py index 99a386cd45e..9f936bb8035 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py @@ -17,7 +17,7 @@ from core.app.entities.queue_entities import ( QueueWorkflowSucceededEvent, ) from core.app.entities.task_entities import StreamEvent -from graphon.entities.pause_reason import HumanInputRequired +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus from models.enums import MessageStatus from models.execution_extra_content import HumanInputContent diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index 4285fe088c6..7ab15d40e30 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -50,10 +50,10 @@ from core.app.entities.task_entities import ( ReasoningChunkStreamResponse, ) from core.base.tts.app_generator_tts_publisher import AudioTrunk +from core.workflow.nodes.human_input.entities import UserActionConfig +from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType from core.workflow.system_variables import build_system_variables -from graphon.entities.pause_reason import PauseReasonType from graphon.enums import BuiltinNodeTypes -from graphon.nodes.human_input.entities import UserActionConfig from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now from models.enums import MessageStatus @@ -168,7 +168,7 @@ class TestAdvancedChatGenerateTaskPipeline: assert response.data.paused_nodes == ["node-1"] assert response.data.reasons == [ { - "TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, + "TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, "form_id": "form-1", "node_id": "node-1", "node_title": "Approval", diff --git a/api/tests/unit_tests/core/app/apps/test_pause_resume.py b/api/tests/unit_tests/core/app/apps/test_pause_resume.py index 30961051a75..835a8c22dd0 100644 --- a/api/tests/unit_tests/core/app/apps/test_pause_resume.py +++ b/api/tests/unit_tests/core/app/apps/test_pause_resume.py @@ -5,7 +5,7 @@ from typing import Any from pytest_mock import MockerFixture -import graphon.nodes.human_input.entities # noqa: F401 +import core.workflow.nodes.human_input.entities # noqa: F401 from core.app.apps.advanced_chat import app_generator as adv_app_gen_module from core.app.apps.workflow import app_generator as wf_app_gen_module from core.app.entities.app_invoke_entities import InvokeFrom diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py index 69ed5919d27..0c297d50a01 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py @@ -22,8 +22,9 @@ from core.app.entities.queue_entities import ( QueueWorkflowStartedEvent, QueueWorkflowSucceededEvent, ) +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import default_system_variables -from graphon.entities.pause_reason import HumanInputRequired +from graphon.entities.pause_reason import HitlRequired from graphon.enums import BuiltinNodeTypes from graphon.graph_events import ( GraphRunPausedEvent, @@ -344,10 +345,20 @@ class TestWorkflowBasedAppRunner: "core.app.apps.workflow_app_runner.dispatch_human_input_email_task", _Dispatch(), ) + monkeypatch.setattr( + "core.app.apps.workflow_app_runner.enrich_graph_pause_reasons", + lambda **_: [ + HumanInputRequired( + form_id="form", + form_content="content", + node_id="node-1", + node_title="Node", + ) + ], + ) - reason = HumanInputRequired( - form_id="form", - form_content="content", + reason = HitlRequired( + session_id="form", node_id="node-1", node_title="Node", ) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py index 10fb2271f49..778c6482635 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py @@ -4,7 +4,8 @@ import pytest from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.queue_entities import QueueWorkflowPausedEvent -from graphon.entities.pause_reason import HumanInputRequired +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from graphon.entities.pause_reason import HitlRequired from graphon.graph_events import GraphRunPausedEvent @@ -17,6 +18,8 @@ class _DummyQueueManager: class _DummyRuntimeState: + variable_pool = object() + def get_paused_nodes(self): return ["node-1"] @@ -36,7 +39,11 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch) runner = WorkflowBasedAppRunner(queue_manager=queue_manager, app_id="app-id") workflow_entry = _DummyWorkflowEntry() - reason = HumanInputRequired( + graph_reason = HitlRequired(session_id="form-123", node_id="node-1", node_title="Review") + event = GraphRunPausedEvent(reasons=[graph_reason], outputs={}) + + email_task = MagicMock() + enriched_reason = HumanInputRequired( form_id="form-123", form_content="content", inputs=[], @@ -44,9 +51,10 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch) node_id="node-1", node_title="Review", ) - event = GraphRunPausedEvent(reasons=[reason], outputs={}) - - email_task = MagicMock() + monkeypatch.setattr( + "core.app.apps.workflow_app_runner.enrich_graph_pause_reasons", + lambda **_: [enriched_reason], + ) monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_email_task", email_task) runner._handle_event(workflow_entry, event) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py index 098a63a8f91..c0052bc5bab 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py @@ -10,17 +10,18 @@ from core.app.apps.workflow.app_runner import WorkflowAppRunner from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueWorkflowPausedEvent from core.app.entities.task_entities import HumanInputRequiredResponse, WorkflowPauseStreamResponse -from core.workflow.system_variables import build_system_variables -from graphon.entities import WorkflowStartReason -from graphon.entities.pause_reason import HumanInputRequired -from graphon.graph_events import GraphRunPausedEvent -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( ParagraphInputConfig, SelectInputConfig, StringListSource, UserActionConfig, ) -from graphon.nodes.human_input.enums import ValueSourceType +from core.workflow.nodes.human_input.enums import ValueSourceType +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from core.workflow.system_variables import build_system_variables +from graphon.entities import WorkflowStartReason +from graphon.entities.pause_reason import HitlRequired +from graphon.graph_events import GraphRunPausedEvent from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account from models.human_input import RecipientType @@ -56,6 +57,8 @@ class _RecordingWorkflowAppRunner(WorkflowAppRunner): class _FakeRuntimeState: + variable_pool = object() + def get_paused_nodes(self): return ["node-pause-1"] @@ -92,9 +95,19 @@ def _build_runner(): ) -def test_graph_run_paused_event_emits_queue_pause_event(): +def test_graph_run_paused_event_emits_queue_pause_event(monkeypatch: pytest.MonkeyPatch): runner = _build_runner() - reason = HumanInputRequired( + graph_reason = HitlRequired( + session_id="form-1", + node_id="node-human", + node_title="Human Step", + ) + event = GraphRunPausedEvent(reasons=[graph_reason], outputs={"foo": "bar"}) + workflow_entry = SimpleNamespace( + graph_engine=SimpleNamespace(graph_runtime_state=_FakeRuntimeState()), + ) + + enriched_reason = HumanInputRequired( form_id="form-1", form_content="content", inputs=[], @@ -102,9 +115,9 @@ def test_graph_run_paused_event_emits_queue_pause_event(): node_id="node-human", node_title="Human Step", ) - event = GraphRunPausedEvent(reasons=[reason], outputs={"foo": "bar"}) - workflow_entry = SimpleNamespace( - graph_engine=SimpleNamespace(graph_runtime_state=_FakeRuntimeState()), + monkeypatch.setattr( + "core.app.apps.workflow_app_runner.enrich_graph_pause_reasons", + lambda **_: [enriched_reason], ) runner._handle_event(workflow_entry, event) @@ -112,7 +125,7 @@ def test_graph_run_paused_event_emits_queue_pause_event(): assert len(runner.published_events) == 1 queue_event = runner.published_events[0] assert isinstance(queue_event, QueueWorkflowPausedEvent) - assert queue_event.reasons == [reason] + assert queue_event.reasons == [enriched_reason] assert queue_event.outputs == {"foo": "bar"} assert queue_event.paused_nodes == ["node-pause-1"] diff --git a/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py b/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py index 3cb180b0ccb..18e724ec48b 100644 --- a/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py @@ -7,14 +7,16 @@ import pytest from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity +from core.app.layers import pause_state_persist_layer as pause_layer_module from core.app.layers.pause_state_persist_layer import ( PauseStatePersistenceLayer, WorkflowResumptionContext, _AdvancedChatAppGenerateEntityWrapper, _WorkflowGenerateEntityWrapper, ) +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import SystemVariableKey -from graphon.entities.pause_reason import SchedulingPause +from graphon.entities.pause_reason import HitlRequired, SchedulingPause from graphon.graph_engine.entities.commands import GraphEngineCommand from graphon.graph_engine.layers.base import GraphEngineLayerNotInitializedError from graphon.graph_events import ( @@ -263,6 +265,63 @@ class TestPauseStatePersistenceLayer: assert isinstance(pause_reasons, list) + def test_on_event_enriches_hitl_pause_reasons_before_persisting(self, monkeypatch: pytest.MonkeyPatch): + session_factory = Mock(name="session_factory") + generate_entity = self._create_generate_entity(workflow_execution_id="run-123") + layer = PauseStatePersistenceLayer( + session_factory=session_factory, + state_owner_user_id="owner-123", + generate_entity=generate_entity, + ) + + mock_repo = Mock() + mock_factory = Mock(return_value=mock_repo) + mock_form_repository = Mock(name="form_repository") + enriched_reason = HumanInputRequired( + form_id="form-123", + form_content="Rendered content", + inputs=[], + actions=[], + node_id="node-123", + node_title="Ask for approval", + ) + enrich_mock = Mock(return_value=[enriched_reason]) + monkeypatch.setattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository", mock_factory) + monkeypatch.setattr( + pause_layer_module, + "HumanInputFormSubmissionRepository", + Mock(return_value=mock_form_repository), + raising=False, + ) + monkeypatch.setattr( + pause_layer_module, + "enrich_graph_pause_reasons", + enrich_mock, + raising=False, + ) + + graph_runtime_state = MockReadOnlyGraphRuntimeState( + workflow_execution_id="run-123", + ) + command_channel = MockCommandChannel() + layer.initialize(graph_runtime_state, command_channel) + + raw_reason = HitlRequired( + session_id="session-123", + node_id="node-123", + node_title="Ask for approval", + ) + event = GraphRunPausedEvent(reasons=[raw_reason], outputs={}) + + layer.on_event(event) + + enrich_mock.assert_called_once_with( + reasons=[raw_reason], + form_repository=mock_form_repository, + variable_pool=graph_runtime_state.variable_pool, + ) + assert mock_repo.create_workflow_pause.call_args.kwargs["pause_reasons"] == [enriched_reason] + def test_on_event_ignores_non_paused_events(self, monkeypatch: pytest.MonkeyPatch): session_factory = Mock(name="session_factory") layer = PauseStatePersistenceLayer( diff --git a/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py b/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py index d0849e7b881..ab806876e86 100644 --- a/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py +++ b/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py @@ -4,7 +4,7 @@ from core.entities.execution_extra_content import ( HumanInputFormDefinition, HumanInputFormSubmissionData, ) -from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig +from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig from models.execution_extra_content import ExecutionContentType diff --git a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py index 27c32d47ee2..f8c6f6d81de 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py @@ -21,11 +21,11 @@ from core.workflow.human_input_adapter import ( ExternalRecipient, MemberRecipient, ) -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( FormDefinition, UserActionConfig, ) -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.human_input import ( EmailExternalRecipientPayload, diff --git a/api/tests/unit_tests/core/repositories/test_human_input_repository.py b/api/tests/unit_tests/core/repositories/test_human_input_repository.py index 780687bec53..5cb17a53e7b 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_repository.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_repository.py @@ -29,8 +29,8 @@ from core.workflow.human_input_adapter import ( MemberRecipient, WebAppDeliveryMethod, ) -from graphon.nodes.human_input.entities import HumanInputNodeData, UserActionConfig -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserActionConfig +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.human_input import HumanInputFormRecipient, RecipientType diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py index a393dab7d9d..86960db44c1 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py @@ -10,6 +10,18 @@ from core.repositories.human_input_repository import ( HumanInputFormRepository, ) from core.workflow.node_runtime import DifyHumanInputNodeRuntime +from core.workflow.nodes.human_input.callback import ( + DifyHITLCallback, +) +from core.workflow.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + HumanInputNodeData, + SelectInputConfig, + StringListSource, + UserActionConfig, +) +from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.file import File, FileTransferMethod, FileType @@ -25,15 +37,6 @@ from graphon.graph_events import ( from graphon.nodes.base.entities import OutputVariableEntity from graphon.nodes.end.end_node import EndNode from graphon.nodes.end.entities import EndNodeData -from graphon.nodes.human_input.entities import ( - FileInputConfig, - FileListInputConfig, - HumanInputNodeData, - SelectInputConfig, - StringListSource, - UserActionConfig, -) -from graphon.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.start.entities import StartNodeData from graphon.nodes.start.start_node import StartNode @@ -83,6 +86,7 @@ class StaticForm(HumanInputFormEntity): action_id: str | None = None data: Mapping[str, Any] | None = None status_value: HumanInputFormStatus = HumanInputFormStatus.WAITING + created: datetime = naive_utc_now() expiration: datetime = naive_utc_now() + timedelta(days=1) @property @@ -105,6 +109,10 @@ class StaticForm(HumanInputFormEntity): def selected_action_id(self) -> str | None: return self.action_id + @property + def created_at(self) -> datetime: + return self.created + @property def submitted_data(self) -> Mapping[str, Any] | None: return self.data @@ -188,27 +196,33 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor human_a_config = {"id": "human_a", "data": human_data.model_dump()} human_a_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) human_a_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] + human_a_callback = DifyHITLCallback( + form_repository=repo, + node_data=human_data, + file_reference_factory=_TestFileReferenceFactory(), + ) human_a = HumanInputNode( node_id=human_a_config["id"], data=human_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, - form_repository=repo, - file_reference_factory=_TestFileReferenceFactory(), - runtime=human_a_runtime, + hitl_callback=human_a_callback, ) human_b_config = {"id": "human_b", "data": human_data.model_dump()} human_b_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) human_b_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] + human_b_callback = DifyHITLCallback( + form_repository=repo, + node_data=human_data, + file_reference_factory=_TestFileReferenceFactory(), + ) human_b = HumanInputNode( node_id=human_b_config["id"], data=human_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, - form_repository=repo, - file_reference_factory=_TestFileReferenceFactory(), - runtime=human_b_runtime, + hitl_callback=human_b_callback, ) end_data = EndNodeData( 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 4e719d74000..f24f02a130f 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 @@ -27,12 +27,12 @@ from core.workflow.nodes.agent_v2.session_store import ( WorkflowAgentRuntimeSessionStore, WorkflowAgentSessionScope, ) +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.entities import GraphInitParams -from graphon.entities.pause_reason import HumanInputRequired +from graphon.entities.pause_reason import HitlRequired from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType from graphon.node_events import PauseRequestedEvent, StreamCompletedEvent -from graphon.nodes.human_input.entities import UserActionConfig from graphon.runtime import GraphRuntimeState from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding @@ -510,7 +510,7 @@ def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot(): node = _node(scenario=FakeAgentBackendScenario.PAUSED, session_store=store) # ENG-636: the PAUSED scenario emits a dify.ask_human deferred call, so the - # node now builds a HITL form and pauses with HumanInputRequired. Stub the + # node now builds a HITL form and pauses with HitlRequired. Stub the # form repository so the unit test stays DB-free. fake_repo = MagicMock() fake_repo.create_form.return_value = MagicMock(id="form-1") @@ -520,8 +520,8 @@ def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot(): assert len(events) == 1 assert isinstance(events[0], PauseRequestedEvent) - assert isinstance(events[0].reason, HumanInputRequired) - assert events[0].reason.form_id == "form-1" + assert isinstance(events[0].reason, HitlRequired) + assert events[0].reason.session_id == "form-1" assert events[0].reason.node_id == "agent-node" fake_repo.create_form.assert_called_once() assert store.saved @@ -585,7 +585,7 @@ def test_agent_node_repauses_when_resumed_form_still_waiting(monkeypatch): form_id="form-1", form_content="Approve?", inputs=[], - actions=[UserActionConfig(id="ok", title="OK")], + actions=[], node_id="agent-node", node_title="Budget review", ) @@ -601,7 +601,7 @@ def test_agent_node_repauses_when_resumed_form_still_waiting(monkeypatch): assert len(events) == 1 assert isinstance(events[0], PauseRequestedEvent) - assert isinstance(events[0].reason, HumanInputRequired) + assert isinstance(events[0].reason, HitlRequired) assert client.request is None # no second Agent run was created diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py index 5f656f13a29..3caa37cb3d5 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py @@ -22,13 +22,14 @@ from core.workflow.nodes.agent_v2.ask_human_hitl import ( build_delivery_methods, parse_ask_human_args, ) -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( FileInputConfig, FileListInputConfig, ParagraphInputConfig, SelectInputConfig, ) -from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit +from core.workflow.nodes.human_input.enums import ButtonStyle, TimeoutUnit +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from models.agent_config_entities import AgentHumanContactConfig @@ -229,7 +230,7 @@ def test_pause_reason_requires_workflow_run_id() -> None: ) -def test_pause_reason_builds_form_and_returns_human_input_required() -> None: +def test_pause_reason_builds_form_and_returns_dify_pause_reason() -> None: repo = _fake_repository(form_id="form-xyz") contacts = [AgentHumanContactConfig(email="a@x.com")] @@ -251,6 +252,7 @@ def test_pause_reason_builds_form_and_returns_human_input_required() -> None: assert result is not None assert result.form_id == "form-xyz" + assert isinstance(result, HumanInputRequired) assert result.node_id == "node-1" assert result.node_title == "Approve?" # args.title wins over default assert [i.output_variable_name for i in result.inputs] == ["note"] @@ -321,6 +323,7 @@ def test_pause_reason_select_default_flows_into_resolved_defaults() -> None: repository=repo, ) assert result is not None + params: FormCreateParams = repo.create_form.call_args.args[0] assert result.resolved_default_values == {"tier": "t1"} diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_resume.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_resume.py index 8f7454313df..921966cc745 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_resume.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_resume.py @@ -4,15 +4,16 @@ from __future__ import annotations from datetime import datetime +import pytest from dify_agent.layers.ask_human import AskHumanToolResult from core.workflow.nodes.agent_v2.ask_human_resume import ( build_deferred_tool_results, map_form_to_outcome, ) -from graphon.entities.pause_reason import HumanInputRequired -from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig -from graphon.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired def _form_definition_json() -> str: @@ -64,18 +65,17 @@ def test_map_timeout_form_to_timeout_result() -> None: assert outcome.deferred_result.action is None -def test_map_expired_form_to_timeout_result() -> None: - outcome = map_form_to_outcome( - status=HumanInputFormStatus.EXPIRED, - selected_action_id=None, - submitted_data=None, - rendered_content="x", - form_definition=_form_definition_json(), - form_id="form-1", - node_id="node-1", - ) - assert outcome.deferred_result is not None - assert outcome.deferred_result.status == "timeout" +def test_map_expired_form_rejects_invalid_resume_state() -> None: + with pytest.raises(AssertionError, match="globally expired ask_human form"): + map_form_to_outcome( + status=HumanInputFormStatus.EXPIRED, + selected_action_id=None, + submitted_data=None, + rendered_content="x", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) def test_map_waiting_form_rebuilds_pause() -> None: diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_dify_owned_contracts.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_dify_owned_contracts.py new file mode 100644 index 00000000000..f04921573ae --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_dify_owned_contracts.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime + +import pytest +from pydantic import TypeAdapter + +from core.workflow.nodes.human_input._exc import ExtensionsNotSetErrorValueError +from core.workflow.nodes.human_input.entities import ( + FileInputConfig, + FormDefinition, + ParagraphInputConfig, + SelectInputConfig, + UserActionConfig, +) +from core.workflow.nodes.human_input.enums import ButtonStyle, FormInputType, ValueSourceType +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from core.workflow.nodes.human_input.session_binding import SessionBinding +from graphon.file import FileType + + +def test_session_binding_identity_mapping() -> None: + binding = SessionBinding() + + assert binding.issue_session_id_for_form(form_id="form-1") == "form-1" + assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1" + + +def test_human_input_node_contracts_accept_legacy_json_payload() -> None: + payload = { + "form_content": "Please confirm", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "name", + "default": { + "type": "constant", + "selector": [], + "value": "Alice", + }, + }, + { + "type": "select", + "output_variable_name": "decision", + "option_source": { + "type": "constant", + "selector": [], + "value": ["approve", "reject"], + }, + }, + ], + "user_actions": [ + { + "id": "approve", + "title": "Approve", + "button_style": "primary", + } + ], + "rendered_content": "Please confirm", + "expiration_time": "2024-01-01T00:00:00Z", + "default_values": {"name": "Alice"}, + "node_title": "Human Input", + "display_in_ui": True, + } + + restored = TypeAdapter(FormDefinition).validate_json(json.dumps(payload)) + + assert isinstance(restored.inputs[0], ParagraphInputConfig) + assert isinstance(restored.inputs[1], SelectInputConfig) + assert restored.inputs[0].default is not None + assert restored.inputs[0].default.type == ValueSourceType.CONSTANT + assert restored.inputs[0].default.value == "Alice" + assert restored.inputs[1].option_source.type == ValueSourceType.CONSTANT + assert restored.inputs[1].option_source.value == ["approve", "reject"] + assert restored.user_actions == [UserActionConfig(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY)] + + +def test_human_input_required_pause_reason_keeps_legacy_payload_shape() -> None: + payload = { + "TYPE": "human_input_required", + "form_id": "form-1", + "form_content": "Please confirm", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "name", + "default": { + "type": "constant", + "selector": [], + "value": "Alice", + }, + } + ], + "actions": [ + { + "id": "approve", + "title": "Approve", + "button_style": "primary", + } + ], + "node_id": "node-1", + "node_title": "Human Input", + "resolved_default_values": {"name": "Alice"}, + } + + restored = TypeAdapter(HumanInputRequired).validate_json(json.dumps(payload)) + + assert restored.TYPE.value == "human_input_required" + assert restored.actions[0].button_style == ButtonStyle.PRIMARY + assert restored.model_dump(mode="json")["TYPE"] == "human_input_required" + + +def test_form_definition_dump_keeps_public_json_shape() -> None: + definition = FormDefinition( + form_content="Please confirm", + inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + user_actions=[UserActionConfig(id="approve", title="Approve")], + rendered_content="rendered", + expiration_time=datetime(2024, 1, 1, tzinfo=UTC), + default_values={"name": "Alice"}, + node_title="Ask Name", + display_in_ui=True, + ) + + payload = definition.model_dump(mode="json") + + assert payload["expiration_time"] == "2024-01-01T00:00:00Z" + assert payload["user_actions"][0]["id"] == "approve" + assert payload["inputs"][0]["type"] == "paragraph" + + +def test_custom_file_input_requires_extensions() -> None: + with pytest.raises(ExtensionsNotSetErrorValueError): + FileInputConfig( + output_variable_name="attachment", + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[], + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index 231000817a8..4f70787052d 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -31,12 +31,10 @@ from core.workflow.human_input_adapter import ( _WebAppDeliveryConfig, ) from core.workflow.node_runtime import DifyHumanInputNodeRuntime -from core.workflow.system_variables import build_system_variables -from graphon.entities import GraphInitParams -from graphon.file import File, FileTransferMethod, FileType -from graphon.node_events import PauseRequestedEvent -from graphon.node_events.node import StreamCompletedEvent -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.callback import ( + DifyHITLCallback, +) +from core.workflow.nodes.human_input.entities import ( FileInputConfig, FileListInputConfig, HumanInputNodeData, @@ -46,13 +44,18 @@ from graphon.nodes.human_input.entities import ( StringSource, UserActionConfig, ) -from graphon.nodes.human_input.enums import ( +from core.workflow.nodes.human_input.enums import ( ButtonStyle, FormInputType, HumanInputFormStatus, TimeoutUnit, ValueSourceType, ) +from core.workflow.system_variables import build_system_variables +from graphon.entities import GraphInitParams +from graphon.file import File, FileTransferMethod, FileType +from graphon.node_events import PauseRequestedEvent +from graphon.node_events.node import StreamCompletedEvent from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.protocols import FileReferenceFactoryProtocol from graphon.runtime import GraphRuntimeState, VariablePool @@ -69,6 +72,7 @@ class _InMemoryFormEntity(HumanInputFormEntity): data: Mapping[str, Any] | None = None is_submitted: bool = False status_value: HumanInputFormStatus = HumanInputFormStatus.WAITING + created: datetime = field(default_factory=naive_utc_now) expiration: datetime = field(default_factory=lambda: naive_utc_now() + timedelta(days=1)) @property @@ -91,6 +95,10 @@ class _InMemoryFormEntity(HumanInputFormEntity): def selected_action_id(self) -> str | None: return self.action_id + @property + def created_at(self) -> datetime: + return self.created + @property def submitted_data(self) -> Mapping[str, Any] | None: return self.data @@ -172,13 +180,19 @@ def _build_human_input_node( node_data if isinstance(node_data, HumanInputNodeData) else HumanInputNodeData.model_validate(node_data) ) runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] + callback = DifyHITLCallback( + form_repository=runtime.build_form_repository(), + node_data=typed_node_data, + delivery_methods=runtime._resolve_delivery_methods(node_data=typed_node_data), + display_in_ui=runtime._display_in_ui(node_data=typed_node_data), + file_reference_factory=_TestFileReferenceFactory(), + ) return HumanInputNode( node_id=node_id, data=typed_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, - file_reference_factory=_TestFileReferenceFactory(), - runtime=runtime, + hitl_callback=callback, ) @@ -523,7 +537,8 @@ class TestHumanInputNodeVariableResolution: assert isinstance(pause_event, PauseRequestedEvent) expected_values = {"user_name": "Jane Doe"} - assert pause_event.reason.resolved_default_values == expected_values + create_params = mock_repo.create_form.call_args.args[0] + assert create_params.resolved_default_values == expected_values params = mock_repo.create_form.call_args.args[0] assert params.resolved_default_values == expected_values diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index 9ee7b79cc42..a1cb0af85fe 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -4,17 +4,10 @@ from types import SimpleNamespace from typing import Any from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom -from core.workflow.node_runtime import DifyHumanInputNodeRuntime -from core.workflow.system_variables import default_system_variables -from graphon.entities import GraphInitParams -from graphon.enums import BuiltinNodeTypes -from graphon.file import File, FileTransferMethod, FileType -from graphon.graph_events import ( - NodeRunHumanInputFormFilledEvent, - NodeRunHumanInputFormTimeoutEvent, - NodeRunStartedEvent, +from core.workflow.nodes.human_input.callback import ( + DifyHITLCallback, ) -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( FileInputConfig, FileListInputConfig, HumanInputNodeData, @@ -23,7 +16,15 @@ from graphon.nodes.human_input.entities import ( StringListSource, UserActionConfig, ) -from graphon.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.system_variables import default_system_variables +from graphon.entities import GraphInitParams +from graphon.enums import BuiltinNodeTypes +from graphon.file import File, FileTransferMethod, FileType +from graphon.graph_events import ( + NodeRunStartedEvent, + NodeRunSucceededEvent, +) from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.protocols import FileReferenceFactoryProtocol from graphon.runtime import GraphRuntimeState, VariablePool @@ -67,16 +68,17 @@ def _create_human_input_node( if isinstance(config["data"], HumanInputNodeData) else HumanInputNodeData.model_validate(config["data"]) ) - runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) - runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] + callback = DifyHITLCallback( + form_repository=repo, + node_data=node_data, + file_reference_factory=_TestFileReferenceFactory(), + ) return HumanInputNode( node_id=config["id"], data=node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, - form_repository=repo, - file_reference_factory=_TestFileReferenceFactory(), - runtime=runtime, + hitl_callback=callback, ) @@ -232,26 +234,25 @@ def test_human_input_node_emits_form_filled_event_before_succeeded(): events = list(node.run()) assert isinstance(events[0], NodeRunStartedEvent) - assert isinstance(events[1], NodeRunHumanInputFormFilledEvent) + assert isinstance(events[1], NodeRunSucceededEvent) - filled_event = events[1] - assert filled_event.node_title == "Human Input" - assert filled_event.rendered_content == ( - "Please enter your name:\n\nAlice\nDecision: approve\nAttachment: [file]\nAttachments: [1 files]" + completed_event = events[1] + assert completed_event.node_run_result.outputs["__rendered_content"] == StringSegment( + value="Please enter your name:\n\nAlice\nDecision: approve\nAttachment: [file]\nAttachments: [1 files]" ) - assert filled_event.action_id == "Accept" - assert filled_event.action_text == "Approve" - assert filled_event.submitted_data["name"] == StringSegment(value="Alice") - assert filled_event.submitted_data["decision"] == StringSegment(value="approve") - assert isinstance(filled_event.submitted_data["attachment"], FileSegment) - assert filled_event.submitted_data["attachment"].value_type == SegmentType.FILE - assert filled_event.submitted_data["attachment"].value.filename == "resume.pdf" - assert filled_event.submitted_data["attachment"].value.type == FileType.DOCUMENT - assert filled_event.submitted_data["attachment"].value.transfer_method == FileTransferMethod.REMOTE_URL - assert isinstance(filled_event.submitted_data["attachments"], ArrayFileSegment) - assert filled_event.submitted_data["attachments"].value_type == SegmentType.ARRAY_FILE - assert filled_event.submitted_data["attachments"].value[0].filename == "a.png" - assert filled_event.submitted_data["attachments"].value[0].type == FileType.IMAGE + assert completed_event.node_run_result.outputs["__action_id"] == StringSegment(value="Accept") + assert completed_event.node_run_result.outputs["__action_value"] == StringSegment(value="Approve") + assert completed_event.node_run_result.inputs["name"] == StringSegment(value="Alice") + assert completed_event.node_run_result.inputs["decision"] == StringSegment(value="approve") + assert isinstance(completed_event.node_run_result.inputs["attachment"], FileSegment) + assert completed_event.node_run_result.inputs["attachment"].value_type == SegmentType.FILE + assert completed_event.node_run_result.inputs["attachment"].value.filename == "resume.pdf" + assert completed_event.node_run_result.inputs["attachment"].value.type == FileType.DOCUMENT + assert completed_event.node_run_result.inputs["attachment"].value.transfer_method == FileTransferMethod.REMOTE_URL + assert isinstance(completed_event.node_run_result.inputs["attachments"], ArrayFileSegment) + assert completed_event.node_run_result.inputs["attachments"].value_type == SegmentType.ARRAY_FILE + assert completed_event.node_run_result.inputs["attachments"].value[0].filename == "a.png" + assert completed_event.node_run_result.inputs["attachments"].value[0].type == FileType.IMAGE def test_human_input_node_emits_timeout_event_before_succeeded(): @@ -260,7 +261,5 @@ def test_human_input_node_emits_timeout_event_before_succeeded(): events = list(node.run()) assert isinstance(events[0], NodeRunStartedEvent) - assert isinstance(events[1], NodeRunHumanInputFormTimeoutEvent) - - timeout_event = events[1] - assert timeout_event.node_title == "Human Input" + assert isinstance(events[1], NodeRunSucceededEvent) + assert events[1].node_run_result.edge_source_handle == "__timeout__" diff --git a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py index 4c67f3fb028..d8d3c1ecc2e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py @@ -242,7 +242,7 @@ def test_extract_text_from_pdf(mock_pdf_document): mock_text_page = Mock() mock_text_page.get_text_range.return_value = "PDF content" mock_page.get_textpage.return_value = mock_text_page - mock_pdf_document.return_value = [mock_page] + mock_pdf_document.return_value.__enter__.return_value = [mock_page] text = _extract_text_from_pdf(b"%PDF-1.5\n%Test PDF content") assert text == "PDF content" 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 f53a8eba734..d930b0d9076 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 @@ -1,9 +1,14 @@ +from unittest.mock import Mock + import pytest +from core.repositories.human_input_repository import HumanInputFormSubmissionRepository from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons -from graphon.entities.pause_reason import PauseReasonType +from core.workflow.nodes.human_input.boundary import enrich_graph_pause_reasons +from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType +from graphon.entities.pause_reason import HitlRequired -_HUMAN_INPUT_REASON = {"TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "f1"} +_HUMAN_INPUT_REASON = {"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, "form_id": "f1"} @pytest.mark.parametrize( @@ -61,3 +66,21 @@ def test_pause_reason_payload_carries_approval_channels_through_factory(): assert payload.approval_channels == ["console"] assert payload.form_token is None + + +def test_enrich_graph_pause_reasons_raises_when_hitl_form_record_is_missing(): + form_repository = Mock(spec=HumanInputFormSubmissionRepository) + form_repository.get_by_form_id.return_value = None + + with pytest.raises(LookupError, match="form-123"): + enrich_graph_pause_reasons( + reasons=[ + HitlRequired( + session_id="form-123", + node_id="node-1", + node_title="Ask Name", + ) + ], + form_repository=form_repository, + variable_pool=None, + ) diff --git a/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py b/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py index cc83a17dfc3..868d453bbd1 100644 --- a/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py +++ b/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py @@ -8,13 +8,14 @@ from core.entities.execution_extra_content import ( HumanInputContent, HumanInputFormDefinition, ) -from graphon.entities.pause_reason import HumanInputRequired -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( FormDefinition, FormInputConfig, HumanInputNodeData, ) -from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType +from core.workflow.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from core.workflow.nodes.human_input.session_binding import SessionBinding def _legacy_form_input_payloads() -> list[dict[str, Any]]: @@ -336,3 +337,10 @@ def test_human_input_required_response_accepts_current_serialized_payload() -> N assert restored.data.inputs[1].output_variable_name == "decision" assert restored.data.actions[0].id == "approve" assert restored.event == "human_input_required" + + +def test_session_binding_identity_mapping() -> None: + binding = SessionBinding() + + assert binding.issue_session_id_for_form(form_id="form-1") == "form-1" + assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1" diff --git a/api/tests/unit_tests/core/workflow/test_human_input_callback.py b/api/tests/unit_tests/core/workflow/test_human_input_callback.py new file mode 100644 index 00000000000..4e4479b1594 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_human_input_callback.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepository +from core.workflow.nodes.human_input.callback import DifyHITLCallback +from core.workflow.nodes.human_input.entities import HumanInputNodeData, ParagraphInputConfig, UserActionConfig +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input.session_binding import SessionBinding +from graphon.runtime import VariablePool +from graphon.variables.factory import build_segment +from libs.datetime_utils import naive_utc_now + + +@dataclass(frozen=True, slots=True) +class _Context: + workflow_execution_id: str + node_id: str + node_title: str = "Human Input" + variable_pool: VariablePool = field(default_factory=VariablePool) + + +def _ctx(workflow_execution_id: str, node_id: str, node_title: str = "Human Input") -> _Context: + return _Context(workflow_execution_id=workflow_execution_id, node_id=node_id, node_title=node_title) + + +def test_session_binding_identity_mapping() -> None: + binding = SessionBinding() + + assert binding.issue_session_id_for_form(form_id="form-1") == "form-1" + assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1" + + +def test_dify_hitl_callback_creates_pause_requested_for_new_form() -> None: + repository = MagicMock(spec=HumanInputFormRepository) + repository.get_form.return_value = None + repository.create_form.return_value = SimpleNamespace(id="form-1") + callback = DifyHITLCallback( + form_repository=repository, + node_data=HumanInputNodeData( + title="Approval", + form_content="Please approve", + inputs=[ParagraphInputConfig(output_variable_name="answer")], + user_actions=[UserActionConfig(id="approve", title="Approve")], + ), + workflow_execution_id="run-1", + ) + + decision = callback(_ctx("run-1", "node-1")) + + assert decision == callback.pause_requested_type(session_id="form-1") + params: FormCreateParams = repository.create_form.call_args.args[0] + assert params.workflow_execution_id == "run-1" + assert params.node_id == "node-1" + + +def test_dify_hitl_callback_returns_completed_for_submitted_form() -> None: + repository = MagicMock(spec=HumanInputFormRepository) + repository.get_form.return_value = SimpleNamespace( + id="form-1", + rendered_content="

Please approve

", + selected_action_id="approve", + submitted_data={"answer": "yes"}, + submitted=True, + status=HumanInputFormStatus.SUBMITTED, + expiration_time=naive_utc_now() + timedelta(hours=1), + ) + callback = DifyHITLCallback( + form_repository=repository, + node_data=HumanInputNodeData( + title="Approval", + form_content="Please approve", + inputs=[ParagraphInputConfig(output_variable_name="answer")], + user_actions=[UserActionConfig(id="approve", title="Approve")], + ), + ) + + decision = callback(_ctx("run-1", "node-1")) + + assert decision.selected_handle == "approve" + assert decision.inputs == {"answer": build_segment("yes")} + assert decision.outputs == { + "answer": build_segment("yes"), + "__action_id": build_segment("approve"), + "__action_value": build_segment("Approve"), + "__rendered_content": build_segment("

Please approve

"), + } + + +def test_dify_hitl_callback_returns_timeout_for_explicit_timeout_form() -> None: + repository = MagicMock(spec=HumanInputFormRepository) + repository.get_form.return_value = SimpleNamespace( + id="form-1", + rendered_content="

Please approve

", + selected_action_id=None, + submitted_data=None, + submitted=False, + status=HumanInputFormStatus.TIMEOUT, + created_at=naive_utc_now(), + expiration_time=naive_utc_now() + timedelta(hours=1), + ) + callback = DifyHITLCallback( + form_repository=repository, + node_data=HumanInputNodeData(title="Approval", form_content="Please approve"), + ) + + decision = callback(_ctx("run-1", "node-1")) + + assert decision.selected_handle == "__timeout__" + assert decision.outputs == { + "__action_id": build_segment(""), + "__action_value": build_segment(""), + "__rendered_content": build_segment("

Please approve

"), + } + + +def test_dify_hitl_callback_returns_timeout_for_waiting_form_past_node_deadline() -> None: + repository = MagicMock(spec=HumanInputFormRepository) + repository.get_form.return_value = SimpleNamespace( + id="form-1", + rendered_content="

Please approve

", + selected_action_id=None, + submitted_data=None, + submitted=False, + status=HumanInputFormStatus.WAITING, + created_at=naive_utc_now(), + expiration_time=naive_utc_now() - timedelta(minutes=1), + ) + callback = DifyHITLCallback( + form_repository=repository, + node_data=HumanInputNodeData(title="Approval", form_content="Please approve"), + ) + + decision = callback(_ctx("run-1", "node-1")) + + assert decision.selected_handle == "__timeout__" + assert decision.outputs == { + "__action_id": build_segment(""), + "__action_value": build_segment(""), + "__rendered_content": build_segment("

Please approve

"), + } + + +def test_dify_hitl_callback_rejects_expired_form_as_invalid_resume_state() -> None: + repository = MagicMock(spec=HumanInputFormRepository) + repository.get_form.return_value = SimpleNamespace( + id="form-1", + rendered_content="

Please approve

", + selected_action_id=None, + submitted_data=None, + submitted=False, + status=HumanInputFormStatus.EXPIRED, + created_at=naive_utc_now() - timedelta(days=8), + expiration_time=naive_utc_now() + timedelta(hours=1), + ) + callback = DifyHITLCallback( + form_repository=repository, + node_data=HumanInputNodeData(title="Approval", form_content="Please approve"), + ) + + with pytest.raises(AssertionError, match="globally expired human input form"): + callback(_ctx("run-1", "node-1")) + + +def test_dify_hitl_callback_rejects_waiting_form_past_global_deadline_as_invalid_resume_state() -> None: + repository = MagicMock(spec=HumanInputFormRepository) + repository.get_form.return_value = SimpleNamespace( + id="form-1", + rendered_content="

Please approve

", + selected_action_id=None, + submitted_data=None, + submitted=False, + status=HumanInputFormStatus.WAITING, + created_at=naive_utc_now() - timedelta(days=8), + expiration_time=naive_utc_now() + timedelta(hours=1), + ) + callback = DifyHITLCallback( + form_repository=repository, + node_data=HumanInputNodeData(title="Approval", form_content="Please approve"), + ) + + with pytest.raises(AssertionError, match="global timeout"): + callback(_ctx("run-1", "node-1")) diff --git a/api/tests/unit_tests/core/workflow/test_human_input_policy.py b/api/tests/unit_tests/core/workflow/test_human_input_policy.py index a1c6fee98cf..99eb1ba01c4 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_policy.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_policy.py @@ -6,8 +6,8 @@ from core.workflow.human_input_policy import ( is_recipient_type_allowed_for_surface, resolve_variable_select_input_options, ) -from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource -from graphon.nodes.human_input.enums import ValueSourceType +from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource +from core.workflow.nodes.human_input.enums import ValueSourceType from graphon.runtime import VariablePool from models.human_input import RecipientType 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 0baee47665c..6a70f2e9afb 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -602,9 +602,7 @@ class TestDifyNodeFactoryCreateNode: ) if constructor_name == "HumanInputNode": - form_repository = sentinel.form_repository - factory._human_input_runtime = MagicMock() - factory._human_input_runtime.build_form_repository.return_value = form_repository + factory._build_human_input_callback = MagicMock(return_value=sentinel.hitl_callback) node_config = {"id": "node-id", "data": {"type": node_type}} result = factory.create_node(node_config) @@ -630,11 +628,8 @@ class TestDifyNodeFactoryCreateNode: assert kwargs["file_reference_factory"] is sentinel.file_reference_factory factory._bound_tool_file_manager_factory.assert_not_called() elif constructor_name == "HumanInputNode": - assert kwargs["form_repository"] is form_repository - assert kwargs["file_reference_factory"] is sentinel.file_reference_factory - assert kwargs["runtime"] is factory._human_input_runtime - assert kwargs["file_reference_factory"] is sentinel.file_reference_factory - factory._human_input_runtime.build_form_repository.assert_called_once_with() + assert kwargs["hitl_callback"] is sentinel.hitl_callback + factory._build_human_input_callback.assert_called_once() elif constructor_name == "ToolNode": assert kwargs["tool_file_manager"] is sentinel.tool_file_manager assert kwargs["runtime"] is sentinel.tool_runtime @@ -643,16 +638,14 @@ class TestDifyNodeFactoryCreateNode: assert kwargs["unstructured_api_config"] is sentinel.unstructured_api_config assert kwargs["http_client"] is sentinel.remote_file_http_client - def test_human_input_node_receives_runtime_repository_and_file_reference_factory( + def test_human_input_node_receives_built_hitl_callback( self, monkeypatch: pytest.MonkeyPatch, factory, ) -> None: created_node = object() constructor = _node_constructor(return_value=created_node) - form_repository = sentinel.form_repository - factory._human_input_runtime = MagicMock() - factory._human_input_runtime.build_form_repository.return_value = form_repository + factory._build_human_input_callback = MagicMock(return_value=sentinel.hitl_callback) monkeypatch.setattr( factory, "_resolve_node_class", @@ -663,10 +656,8 @@ class TestDifyNodeFactoryCreateNode: assert result is created_node kwargs = constructor.call_args.kwargs - assert kwargs["runtime"] is factory._human_input_runtime - assert kwargs["form_repository"] is form_repository - assert kwargs["file_reference_factory"] is sentinel.file_reference_factory - factory._human_input_runtime.build_form_repository.assert_called_once_with() + assert kwargs["hitl_callback"] is sentinel.hitl_callback + factory._build_human_input_callback.assert_called_once() def test_tool_node_receives_tool_file_manager(self, monkeypatch: pytest.MonkeyPatch, factory) -> None: created_node = object() 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 b32b9f74df7..afebc6ee38a 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -34,13 +34,13 @@ from core.workflow.node_runtime import ( build_dify_llm_file_saver, resolve_dify_run_context, ) +from core.workflow.nodes.human_input.entities import FileInputConfig, FileListInputConfig, HumanInputNodeData from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.llm_entities import LLMPollingResult, LLMPollingStatus from graphon.model_runtime.entities.message_entities import AssistantPromptMessage from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelFeature, ModelType from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel -from graphon.nodes.human_input.entities import FileInputConfig, FileListInputConfig, HumanInputNodeData from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType from graphon.variables.segments import ArrayFileSegment, FileSegment diff --git a/api/tests/unit_tests/libs/_human_input/support.py b/api/tests/unit_tests/libs/_human_input/support.py index 0f593507fdb..a71b53bfba8 100644 --- a/api/tests/unit_tests/libs/_human_input/support.py +++ b/api/tests/unit_tests/libs/_human_input/support.py @@ -4,8 +4,8 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any -from graphon.nodes.human_input.entities import FormInputConfig -from graphon.nodes.human_input.enums import TimeoutUnit +from core.workflow.nodes.human_input.entities import FormInputConfig +from core.workflow.nodes.human_input.enums import TimeoutUnit from libs.datetime_utils import naive_utc_now diff --git a/api/tests/unit_tests/libs/_human_input/test_form_service.py b/api/tests/unit_tests/libs/_human_input/test_form_service.py index decd7c484ba..6a2265b3ab9 100644 --- a/api/tests/unit_tests/libs/_human_input/test_form_service.py +++ b/api/tests/unit_tests/libs/_human_input/test_form_service.py @@ -6,11 +6,11 @@ from datetime import timedelta import pytest -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( ParagraphInputConfig, UserActionConfig, ) -from graphon.nodes.human_input.enums import ( +from core.workflow.nodes.human_input.enums import ( TimeoutUnit, ) from libs.datetime_utils import naive_utc_now diff --git a/api/tests/unit_tests/libs/_human_input/test_models.py b/api/tests/unit_tests/libs/_human_input/test_models.py index f6e4c9ec181..727e94ca00b 100644 --- a/api/tests/unit_tests/libs/_human_input/test_models.py +++ b/api/tests/unit_tests/libs/_human_input/test_models.py @@ -6,11 +6,11 @@ from datetime import datetime, timedelta import pytest -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( ParagraphInputConfig, UserActionConfig, ) -from graphon.nodes.human_input.enums import ( +from core.workflow.nodes.human_input.enums import ( TimeoutUnit, ) from libs.datetime_utils import naive_utc_now 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 8f27d0938d0..8c43d354f86 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 @@ -3,10 +3,16 @@ from __future__ import annotations from datetime import UTC, datetime from types import SimpleNamespace -from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig -from graphon.nodes.human_input.enums import FormInputType +from core.workflow.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig +from core.workflow.nodes.human_input.enums import FormInputType +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from graphon.entities.pause_reason import HitlRequired, PauseReasonType from models.human_input import RecipientType -from repositories.sqlalchemy_api_workflow_run_repository import _build_human_input_required_reason +from models.workflow import WorkflowPauseReason +from repositories.sqlalchemy_api_workflow_run_repository import ( + _build_human_input_required_reason, + _PrivateWorkflowPauseEntity, +) def _build_form_model() -> SimpleNamespace: @@ -25,6 +31,7 @@ def _build_form_model() -> SimpleNamespace: id="form-1", node_id="node-1", form_definition=definition.model_dump_json(), + rendered_content="rendered", expiration_time=expiration_time, ) @@ -45,6 +52,7 @@ def test_build_human_input_required_reason_prefers_standalone_web_app_token() -> ) assert reason.node_title == "Ask Name" + assert reason.form_content == "rendered" assert reason.resolved_default_values == {"name": "Alice"} assert not hasattr(reason, "form_token") @@ -62,3 +70,65 @@ def test_build_human_input_required_reason_falls_back_to_console_token() -> None assert reason.node_id == "node-1" assert reason.actions[0].id == "approve" assert not hasattr(reason, "form_token") + + +def test_workflow_pause_reason_from_entity_persists_hitl_type_for_dify_human_input() -> None: + reason_model = WorkflowPauseReason.from_entity( + pause_id="pause-1", + pause_reason=HumanInputRequired( + form_id="form-1", + form_content="content", + inputs=[], + actions=[], + node_id="node-1", + node_title="Ask Name", + ), + ) + + assert reason_model.type_ == PauseReasonType.HITL_REQUIRED + assert reason_model.form_id == "form-1" + assert reason_model.node_id == "node-1" + + +def test_workflow_pause_reason_to_entity_restores_graphon_hitl_reason() -> None: + reason_model = WorkflowPauseReason( + pause_id="pause-1", + type_=PauseReasonType.HITL_REQUIRED, + form_id="form-1", + node_id="node-1", + ) + + reason = reason_model.to_entity() + + assert isinstance(reason, HitlRequired) + assert reason.TYPE == PauseReasonType.HITL_REQUIRED + assert reason.session_id == "form-1" + assert reason.node_id == "node-1" + + +def test_private_workflow_pause_entity_preserves_list_shaped_pause_reasons() -> None: + pause_reasons = [ + HumanInputRequired( + form_id="form-1", + form_content="content", + inputs=[], + actions=[], + node_id="node-1", + node_title="Ask Name", + ) + ] + entity = _PrivateWorkflowPauseEntity( + pause_model=SimpleNamespace( + id="pause-1", + workflow_run_id="run-1", + resumed_at=None, + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ), + reason_models=[], + pause_reasons=pause_reasons, + ) + + result = entity.get_pause_reasons() + + assert isinstance(result, list) + assert result == pause_reasons diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py index df6805bcdf1..5540960ccb6 100644 --- a/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py +++ b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py @@ -6,8 +6,12 @@ from typing import cast from sqlalchemy.orm import Session, sessionmaker -from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig -from graphon.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input.entities import ( + FormDefinition, + ParagraphInputConfig, + UserActionConfig, +) +from core.workflow.nodes.human_input.enums import HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.execution_extra_content import HumanInputContent as HumanInputContentModel from models.human_input import HumanInputForm @@ -17,11 +21,15 @@ from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchem def test_map_human_input_content_populates_submission_data_from_stored_form_submission() -> None: expiration_time = naive_utc_now() + timedelta(days=1) stored_submission_data = {"decision": "approve", "comment": "Looks good"} + rendered_content_template = "Decision: {{#$output.decision#}}, Comment: {{#$output.comment#}}" form_definition = FormDefinition( - form_content="content", - inputs=[], + form_content=rendered_content_template, + inputs=[ + ParagraphInputConfig(output_variable_name="decision"), + ParagraphInputConfig(output_variable_name="comment"), + ], user_actions=[UserActionConfig(id="approve", title="Approve")], - rendered_content="Rendered Approve", + rendered_content=rendered_content_template, expiration_time=expiration_time, node_title="Approval", display_in_ui=True, @@ -32,7 +40,7 @@ def test_map_human_input_content_populates_submission_data_from_stored_form_subm workflow_run_id="workflow-run-1", node_id="node-1", form_definition=form_definition.model_dump_json(), - rendered_content="Rendered Approve", + rendered_content=rendered_content_template, expiration_time=expiration_time, selected_action_id="approve", submitted_data=json.dumps(stored_submission_data), @@ -54,6 +62,7 @@ def test_map_human_input_content_populates_submission_data_from_stored_form_subm assert content is not None assert content.form_submission_data is not None assert content.form_submission_data.submitted_data == stored_submission_data + assert content.form_submission_data.rendered_content == "Decision: approve, Comment: Looks good" def test_map_human_input_content_keeps_waiting_form_without_selected_action() -> None: diff --git a/api/tests/unit_tests/services/test_human_input_file_upload_service.py b/api/tests/unit_tests/services/test_human_input_file_upload_service.py index 08e0ad139c8..54abde9c618 100644 --- a/api/tests/unit_tests/services/test_human_input_file_upload_service.py +++ b/api/tests/unit_tests/services/test_human_input_file_upload_service.py @@ -10,8 +10,8 @@ from sqlalchemy.orm import sessionmaker import models.account as account_module import services.human_input_file_upload_service as service_module +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from graphon.enums import WorkflowExecutionStatus -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.account import Account, Tenant, TenantAccountJoin from models.base import Base diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index 5c26a9ef57d..d23bdd945c6 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -14,8 +14,7 @@ from core.repositories.human_input_repository import ( HumanInputFormRecord, HumanInputFormSubmissionRepository, ) -from graphon.file import File, FileTransferMethod, FileType -from graphon.nodes.human_input.entities import ( +from core.workflow.nodes.human_input.entities import ( FileInputConfig, FileListInputConfig, FormDefinition, @@ -24,7 +23,8 @@ from graphon.nodes.human_input.entities import ( StringListSource, UserActionConfig, ) -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from graphon.file import File, FileTransferMethod, FileType from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now from models.human_input import RecipientType diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index b322909a128..67b3e80da6b 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -2833,6 +2833,8 @@ class TestWorkflowServiceHumanInputOperations: workflow = MagicMock() workflow.environment_variables = [] workflow.graph_dict = {} + node_data = MagicMock() + node_data.extract_variable_selector_to_variable_mapping.return_value = {} with ( patch("services.workflow_service.db"), @@ -2840,12 +2842,16 @@ class TestWorkflowServiceHumanInputOperations: patch("services.workflow_service.WorkflowDraftVariableService"), patch("services.workflow_service.VariablePool") as mock_pool_cls, patch("services.workflow_service.DraftVarLoader"), - patch("services.workflow_service.HumanInputNode.extract_variable_selector_to_variable_mapping"), + patch("services.workflow_service.HumanInputNodeData.model_validate", return_value=node_data), patch("services.workflow_service.load_into_variable_pool"), patch("services.workflow_service.WorkflowEntry.mapping_user_inputs_to_variable_pool"), ): service._build_human_input_variable_pool( - app_model=MagicMock(), workflow=workflow, node_config={}, manual_inputs={}, user_id="user-1" + app_model=MagicMock(), + workflow=workflow, + node_config={"id": "node-1", "data": {}}, + manual_inputs={}, + user_id="user-1", ) mock_pool_cls.assert_called_once() @@ -2896,9 +2902,7 @@ class TestWorkflowServiceFreeNodeExecution: service.validate_features_structure(app, {}) def test_validate_human_input_node_data_error(self, service: WorkflowService) -> None: - with patch( - "graphon.nodes.human_input.entities.HumanInputNodeData.model_validate", side_effect=Exception("error") - ): + with patch("services.workflow_service.HumanInputNodeData.model_validate", side_effect=Exception("error")): with pytest.raises(ValueError, match="Invalid HumanInput node data"): service._validate_human_input_node_data({}) @@ -2910,49 +2914,24 @@ class TestWorkflowServiceFreeNodeExecution: def test_build_human_input_node_for_debugging(self, service: WorkflowService) -> None: """Cover _build_human_input_node_for_debugging.""" workflow = MagicMock() - workflow.id = "wf-1" - workflow.tenant_id = "t-1" - workflow.app_id = "app-1" account = MagicMock() - account.id = "u-1" node_config = {"id": "n-1", "data": {"type": BuiltinNodeTypes.HUMAN_INPUT, "title": "Human Input"}} variable_pool = MagicMock() + node_data = MagicMock() + node_data.title = "Human Input" with ( - patch("services.workflow_service.DifyGraphInitContext") as mock_graph_init_context_cls, - patch("services.workflow_service.GraphRuntimeState"), patch( "services.workflow_service.adapt_human_input_node_data_for_graph", return_value=sentinel.adapted_node_data, ) as mock_adapt_node_data, - patch("services.workflow_service.build_dify_run_context") as mock_build_dify_run_context, - patch("services.workflow_service.DifyFileReferenceFactory") as mock_file_reference_factory_cls, - patch("services.workflow_service.DifyHumanInputNodeRuntime") as mock_runtime_cls, - patch("services.workflow_service.DifyFileReferenceFactory") as mock_file_reference_factory_cls, - patch("services.workflow_service.HumanInputNode") as mock_node_cls, + patch("services.workflow_service.HumanInputNodeData.model_validate", return_value=node_data), ): - mock_node_cls.validate_node_data.return_value = sentinel.node_data node = service._build_human_input_node_for_debugging( workflow=workflow, account=account, node_config=node_config, variable_pool=variable_pool ) - assert node == mock_node_cls.return_value - mock_node_cls.assert_called_once() - mock_graph_init_context_cls.assert_called_once_with( - workflow_id="wf-1", - graph_config=workflow.graph_dict, - run_context=mock_build_dify_run_context.return_value, - call_depth=0, - ) - mock_runtime_cls.assert_called_once_with(mock_build_dify_run_context.return_value) - mock_file_reference_factory_cls.assert_called_once_with(mock_build_dify_run_context.return_value) mock_adapt_node_data.assert_called_once_with(node_config["data"]) - mock_node_cls.validate_node_data.assert_called_once_with(sentinel.adapted_node_data) - mock_file_reference_factory_cls.assert_called_once_with(mock_build_dify_run_context.return_value) - mock_node_cls.assert_called_once_with( - node_id="n-1", - data=sentinel.node_data, - graph_init_params=mock_graph_init_context_cls.return_value.to_graph_init_params.return_value, - graph_runtime_state=ANY, - file_reference_factory=mock_file_reference_factory_cls.return_value, - runtime=mock_runtime_cls.return_value, - ) + assert node.node_id == "n-1" + assert node.title == "Human Input" + assert node.node_data is node_data + assert node.variable_pool is variable_pool diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py index dc3a6a31205..36fe2553f7b 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -17,10 +17,10 @@ from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerat from core.app.entities.task_entities import StreamEvent from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from core.workflow.human_input_policy import FormDisposition, HumanInputSurface -from graphon.entities.pause_reason import HumanInputRequired +from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource +from core.workflow.nodes.human_input.enums import ValueSourceType +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus -from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource -from graphon.nodes.human_input.enums import ValueSourceType from graphon.runtime import GraphRuntimeState, VariablePool from models.enums import CreatorUserRole from models.human_input import RecipientType diff --git a/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py b/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py index 4b677bca62f..5bcb13c360c 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py @@ -12,8 +12,8 @@ from core.workflow.human_input_adapter import ( ExternalRecipient, MemberRecipient, ) +from core.workflow.nodes.human_input.entities import HumanInputNodeData from graphon.enums import BuiltinNodeTypes -from graphon.nodes.human_input.entities import HumanInputNodeData from services import workflow_service as workflow_service_module from services.workflow_service import WorkflowService diff --git a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py index f2203bae25f..be837acd2f0 100644 --- a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py +++ b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py @@ -6,7 +6,7 @@ from typing import Any import pytest -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from tasks import human_input_timeout_tasks as task_module diff --git a/api/uv.lock b/api/uv.lock index b53c89bf5a8..df9effefdf5 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1638,7 +1638,7 @@ requires-dist = [ { name = "gmpy2", specifier = ">=2.3.0,<3.0.0" }, { name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" }, { name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" }, - { name = "graphon", specifier = "==0.5.3" }, + { name = "graphon", specifier = "==0.6.0" }, { name = "gunicorn", specifier = ">=26.0.0,<27.0.0" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "httpx-sse", specifier = "==0.4.3" }, @@ -2989,7 +2989,7 @@ httpx = [ [[package]] name = "graphon" -version = "0.5.3" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, @@ -3001,6 +3001,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-extra-types" }, { name = "pypandoc" }, + { name = "pypdf" }, { name = "pypdfium2" }, { name = "python-docx" }, { name = "pyyaml" }, @@ -3010,9 +3011,9 @@ dependencies = [ { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/02/75c8cc2f946c8b6debe4f71a8a0f41a69cd499073368a8735ca424c6551f/graphon-0.5.3.tar.gz", hash = "sha256:eaa87d5e664acdf14c80e38afce6bc0f14644961de7ce7b059266fe61bc30e0b", size = 271204, upload-time = "2026-06-23T08:13:32.46Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/6c/9ea051ed30dc3306e9e77c4486b5a2e5462af45e35daf230d9ec886eb07e/graphon-0.6.0.tar.gz", hash = "sha256:2d3a386899dc7ab8e9767ab96c694ff7e6eb454c045a1e801505cba9c615160d", size = 264404, upload-time = "2026-06-29T15:26:27.437Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/fb/616f8ecbd184af57dca8380877b149198d944f4a6658cceb353ae02ace92/graphon-0.5.3-py3-none-any.whl", hash = "sha256:a7f070d1e5eef13d25b97cce6d23675b228c1d38f3c656e3dcacaa6be9ccada4", size = 383359, upload-time = "2026-06-23T08:13:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/5b0ece2d61fa091f7d74aea5a48f9cf927202099e8d22b856e0319218e9d/graphon-0.6.0-py3-none-any.whl", hash = "sha256:f1445ccef40c0d0eb50a60af85c1028b26d2d782f357315047acee816acbcb33", size = 376038, upload-time = "2026-06-29T15:26:26.186Z" }, ] [[package]]