mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
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>
This commit is contained in:
parent
cb6179356c
commit
f4ec608ef4
30
.superpowers/sdd/hitl-timeout-semantics-impl-report.md
Normal file
30
.superpowers/sdd/hitl-timeout-semantics-impl-report.md
Normal file
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
*,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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":
|
||||
|
||||
50
api/core/workflow/nodes/human_input/__init__.py
Normal file
50
api/core/workflow/nodes/human_input/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
22
api/core/workflow/nodes/human_input/_exc.py
Normal file
22
api/core/workflow/nodes/human_input/_exc.py
Normal file
@ -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")
|
||||
61
api/core/workflow/nodes/human_input/boundary.py
Normal file
61
api/core/workflow/nodes/human_input/boundary.py
Normal file
@ -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),
|
||||
)
|
||||
329
api/core/workflow/nodes/human_input/callback.py
Normal file
329
api/core/workflow/nodes/human_input/callback.py
Normal file
@ -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),
|
||||
}
|
||||
374
api/core/workflow/nodes/human_input/entities.py
Normal file
374
api/core/workflow/nodes/human_input/entities.py
Normal file
@ -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<field_name>[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)
|
||||
76
api/core/workflow/nodes/human_input/enums.py
Normal file
76
api/core/workflow/nodes/human_input/enums.py
Normal file
@ -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()
|
||||
49
api/core/workflow/nodes/human_input/pause_reason.py
Normal file
49
api/core/workflow/nodes/human_input/pause_reason.py
Normal file
@ -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
|
||||
16
api/core/workflow/nodes/human_input/session_binding.py
Normal file
16
api/core/workflow/nodes/human_input/session_binding.py
Normal file
@ -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()
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
]
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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="",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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=[],
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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__"
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
188
api/tests/unit_tests/core/workflow/test_human_input_callback.py
Normal file
188
api/tests/unit_tests/core/workflow/test_human_input_callback.py
Normal file
@ -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="<p>Please approve</p>",
|
||||
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("<p>Please approve</p>"),
|
||||
}
|
||||
|
||||
|
||||
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="<p>Please approve</p>",
|
||||
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("<p>Please approve</p>"),
|
||||
}
|
||||
|
||||
|
||||
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="<p>Please approve</p>",
|
||||
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("<p>Please approve</p>"),
|
||||
}
|
||||
|
||||
|
||||
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="<p>Please approve</p>",
|
||||
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="<p>Please approve</p>",
|
||||
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"))
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
9
api/uv.lock
generated
9
api/uv.lock
generated
@ -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]]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user