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:
QuantumGhost 2026-07-03 11:45:21 +08:00 committed by GitHub
parent cb6179356c
commit f4ec608ef4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
89 changed files with 2345 additions and 396 deletions

View 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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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,
*,

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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":

View 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",
]

View 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")

View 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),
)

View 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),
}

View 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)

View 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()

View 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

View 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()

View File

@ -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

View File

@ -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:

View File

@ -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",
]

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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

View File

@ -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)
]

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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(

View File

@ -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

View File

@ -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="",

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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",
)

View File

@ -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)

View File

@ -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"]

View File

@ -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(

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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"}

View File

@ -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:

View File

@ -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=[],
)

View File

@ -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

View File

@ -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__"

View File

@ -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"

View File

@ -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,
)

View File

@ -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"

View 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"))

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
View File

@ -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]]