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 03:45:21 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent cb6179356c
commit f4ec608ef4
89 changed files with 2345 additions and 396 deletions
@@ -14,11 +14,16 @@ from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from core.workflow.human_input_adapter import DeliveryMethodType
from core.workflow.nodes.human_input.entities import (
FormDefinition,
SelectInputConfig,
StringListSource,
UserActionConfig,
)
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.entities import WorkflowExecution
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.entities import FormDefinition, SelectInputConfig, StringListSource, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
from graphon.runtime import GraphRuntimeState, VariablePool
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
@@ -17,7 +17,7 @@ from core.workflow.human_input_adapter import (
MemberRecipient,
WebAppDeliveryMethod,
)
from graphon.nodes.human_input.entities import FormDefinition, HumanInputNodeData, UserActionConfig
from core.workflow.nodes.human_input.entities import FormDefinition, HumanInputNodeData, UserActionConfig
from models.account import (
Account,
AccountStatus,
@@ -13,7 +13,11 @@ from core.app.workflow.layers import PersistenceWorkflowInfo, WorkflowPersistenc
from core.repositories.human_input_repository import HumanInputFormEntity, HumanInputFormRepository
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime
from core.workflow.nodes.human_input.callback import (
DifyHITLCallback,
)
from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
from core.workflow.system_variables import build_system_variables
from graphon.enums import WorkflowType
from graphon.graph import Graph
@@ -21,8 +25,6 @@ from graphon.graph_engine import GraphEngine
from graphon.graph_engine.command_channels import InMemoryChannel
from graphon.nodes.end.end_node import EndNode
from graphon.nodes.end.entities import EndNodeData
from graphon.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormStatus
from graphon.nodes.human_input.human_input_node import HumanInputNode
from graphon.nodes.start.entities import StartNodeData
from graphon.nodes.start.start_node import StartNode
@@ -115,14 +117,16 @@ def _build_graph(
UserActionConfig(id="continue", title="Continue"),
],
)
hitl_callback = DifyHITLCallback(
form_repository=form_repository,
node_data=human_data,
)
human_node = HumanInputNode(
node_id="human",
data=human_data,
graph_init_params=params,
graph_runtime_state=runtime_state,
form_repository=form_repository,
file_reference_factory=DifyFileReferenceFactory(params.run_context),
runtime=DifyHumanInputNodeRuntime(params.run_context),
hitl_callback=hitl_callback,
)
end_data = EndNodeData(
@@ -5,7 +5,7 @@ from datetime import timedelta
from decimal import Decimal
from uuid import uuid4
from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig
from core.workflow.nodes.human_input.entities import FormDefinition, UserActionConfig
from libs.datetime_utils import naive_utc_now
from models.account import Account, Tenant, TenantAccountJoin
from models.enums import ConversationFromSource, InvokeFrom
@@ -13,12 +13,13 @@ from sqlalchemy import Engine, delete, select
from sqlalchemy.orm import Session, sessionmaker
from core.workflow.human_input_adapter import DeliveryMethodType
from core.workflow.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormStatus
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from extensions.ext_storage import storage
from graphon.entities import WorkflowExecution
from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType
from graphon.entities.pause_reason import HitlRequired, PauseReasonType
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
from graphon.nodes.human_input.enums import FormInputType, HumanInputFormStatus
from libs.datetime_utils import naive_utc_now
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.human_input import (
@@ -387,6 +388,232 @@ class TestCreateWorkflowPause:
pause_reasons=[],
)
def test_create_workflow_pause_writes_hitl_type_for_dify_human_input_reason(
self,
repository: DifyAPISQLAlchemyWorkflowRunRepository,
db_session_with_containers: Session,
test_scope: _TestScope,
) -> None:
"""Persist Dify human-input reasons using graphon HITL rows while preserving enriched reads."""
workflow_run = _create_workflow_run(
db_session_with_containers,
test_scope,
status=WorkflowExecutionStatus.RUNNING,
)
expiration_time = naive_utc_now()
form_definition = FormDefinition(
form_content="content",
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
user_actions=[UserActionConfig(id="approve", title="Approve")],
rendered_content="rendered",
expiration_time=expiration_time,
default_values={"name": "Alice"},
node_title="Ask Name",
display_in_ui=True,
)
form_model = HumanInputForm(
tenant_id=test_scope.tenant_id,
app_id=test_scope.app_id,
workflow_run_id=workflow_run.id,
node_id="node-1",
form_definition=form_definition.model_dump_json(),
rendered_content="rendered",
status=HumanInputFormStatus.WAITING,
expiration_time=expiration_time,
)
db_session_with_containers.add(form_model)
db_session_with_containers.commit()
pause_entity = repository.create_workflow_pause(
workflow_run_id=workflow_run.id,
state_owner_user_id=test_scope.user_id,
state='{"test": "state"}',
pause_reasons=[
HumanInputRequired(
form_id=form_model.id,
form_content="ignored-at-persistence-boundary",
inputs=[],
actions=[],
node_id="node-1",
node_title="Ask Name",
)
],
)
pause_model = db_session_with_containers.get(WorkflowPause, pause_entity.id)
assert pause_model is not None
test_scope.state_keys.add(pause_model.state_object_key)
reason_models = db_session_with_containers.scalars(
select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_model.id)
).all()
assert len(reason_models) == 1
assert reason_models[0].type_ == PauseReasonType.HITL_REQUIRED
assert reason_models[0].form_id == form_model.id
assert reason_models[0].node_id == "node-1"
pause_reasons = pause_entity.get_pause_reasons()
assert len(pause_reasons) == 1
reason = pause_reasons[0]
assert isinstance(reason, HumanInputRequired)
assert reason.form_id == form_model.id
assert reason.node_id == "node-1"
assert reason.node_title == "Ask Name"
assert reason.form_content == "rendered"
assert reason.resolved_default_values == {"name": "Alice"}
reloaded_pause = repository.get_workflow_pause(workflow_run.id)
assert reloaded_pause is not None
reloaded_reasons = reloaded_pause.get_pause_reasons()
assert len(reloaded_reasons) == 1
reloaded_reason = reloaded_reasons[0]
assert isinstance(reloaded_reason, HumanInputRequired)
assert reloaded_reason.form_id == form_model.id
assert reloaded_reason.node_id == "node-1"
assert reloaded_reason.node_title == "Ask Name"
def test_create_workflow_pause_round_trips_graphon_hitl_reason(
self,
repository: DifyAPISQLAlchemyWorkflowRunRepository,
db_session_with_containers: Session,
test_scope: _TestScope,
) -> None:
"""Persist graphon HITL rows while keeping repository reads Dify-enriched."""
workflow_run = _create_workflow_run(
db_session_with_containers,
test_scope,
status=WorkflowExecutionStatus.RUNNING,
)
expiration_time = naive_utc_now()
form_definition = FormDefinition(
form_content="content",
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
user_actions=[UserActionConfig(id="approve", title="Approve")],
rendered_content="rendered",
expiration_time=expiration_time,
default_values={"name": "Alice"},
node_title="Ask Name",
display_in_ui=True,
)
form_model = HumanInputForm(
tenant_id=test_scope.tenant_id,
app_id=test_scope.app_id,
workflow_run_id=workflow_run.id,
node_id="node-1",
form_definition=form_definition.model_dump_json(),
rendered_content="rendered",
status=HumanInputFormStatus.WAITING,
expiration_time=expiration_time,
)
db_session_with_containers.add(form_model)
db_session_with_containers.commit()
pause_entity = repository.create_workflow_pause(
workflow_run_id=workflow_run.id,
state_owner_user_id=test_scope.user_id,
state='{"test": "state"}',
pause_reasons=[
HitlRequired(
session_id=form_model.id,
node_id="node-1",
node_title="Ask Name",
)
],
)
pause_model = db_session_with_containers.get(WorkflowPause, pause_entity.id)
assert pause_model is not None
test_scope.state_keys.add(pause_model.state_object_key)
reason_models = db_session_with_containers.scalars(
select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_model.id)
).all()
assert len(reason_models) == 1
assert reason_models[0].type_ == PauseReasonType.HITL_REQUIRED
raw_reason = reason_models[0].to_entity()
assert isinstance(raw_reason, HitlRequired)
assert raw_reason.TYPE == PauseReasonType.HITL_REQUIRED
assert raw_reason.session_id == form_model.id
assert raw_reason.node_id == "node-1"
pause_reasons = pause_entity.get_pause_reasons()
assert len(pause_reasons) == 1
reason = pause_reasons[0]
assert isinstance(reason, HumanInputRequired)
assert reason.form_id == form_model.id
assert reason.node_id == "node-1"
assert reason.node_title == "Ask Name"
def test_get_workflow_pause_reads_legacy_human_input_reason(
self,
repository: DifyAPISQLAlchemyWorkflowRunRepository,
db_session_with_containers: Session,
test_scope: _TestScope,
) -> None:
"""Hydrate old legacy HITL rows into the unchanged Dify-facing payload."""
workflow_run = _create_workflow_run(
db_session_with_containers,
test_scope,
status=WorkflowExecutionStatus.PAUSED,
)
expiration_time = naive_utc_now()
form_definition = FormDefinition(
form_content="content",
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
user_actions=[UserActionConfig(id="approve", title="Approve")],
rendered_content="rendered",
expiration_time=expiration_time,
default_values={"name": "Alice"},
node_title="Ask Name",
display_in_ui=True,
)
form_model = HumanInputForm(
tenant_id=test_scope.tenant_id,
app_id=test_scope.app_id,
workflow_run_id=workflow_run.id,
node_id="node-1",
form_definition=form_definition.model_dump_json(),
rendered_content="rendered",
status=HumanInputFormStatus.WAITING,
expiration_time=expiration_time,
)
pause_model = WorkflowPause(
workflow_id=test_scope.workflow_id,
workflow_run_id=workflow_run.id,
state_object_key=f"workflow-state-{uuid4()}.json",
)
db_session_with_containers.add_all([form_model, pause_model])
db_session_with_containers.flush()
reason_model = WorkflowPauseReason(
pause_id=pause_model.id,
type_=PauseReasonType.LEGACY_HUMAN_INPUT_REQUIRED,
form_id=form_model.id,
node_id="node-1",
)
db_session_with_containers.add(reason_model)
db_session_with_containers.commit()
test_scope.state_keys.add(pause_model.state_object_key)
pause_entity = repository.get_workflow_pause(workflow_run.id)
assert pause_entity is not None
pause_reasons = pause_entity.get_pause_reasons()
assert len(pause_reasons) == 1
reason = pause_reasons[0]
assert isinstance(reason, HumanInputRequired)
assert reason.form_id == form_model.id
assert reason.node_id == "node-1"
assert reason.node_title == "Ask Name"
assert reason.form_content == "rendered"
assert reason.resolved_default_values == {"name": "Alice"}
class TestResumeWorkflowPause:
"""Integration tests for resume_workflow_pause."""
@@ -715,7 +942,7 @@ class TestBuildHumanInputRequiredReason:
reason_model = WorkflowPauseReason(
pause_id=pause.id,
type_=PauseReasonType.HUMAN_INPUT_REQUIRED,
type_=PauseReasonType.HITL_REQUIRED,
form_id=form_model.id,
node_id="node-1",
message="",
@@ -738,7 +965,7 @@ class TestBuildHumanInputRequiredReason:
assert isinstance(reason, HumanInputRequired)
assert reason.node_title == "Ask Name"
assert reason.form_content == "content"
assert reason.form_content == "rendered"
assert reason.inputs[0].output_variable_name == "name"
assert reason.actions[0].id == "approve"
assert reason.resolved_default_values == {"name": "Alice"}
@@ -819,7 +1046,7 @@ class TestBuildHumanInputRequiredReason:
reason_model = WorkflowPauseReason(
pause_id=pause.id,
type_=PauseReasonType.HUMAN_INPUT_REQUIRED,
type_=PauseReasonType.HITL_REQUIRED,
form_id=form_model.id,
node_id="node-1",
message="",
@@ -16,8 +16,8 @@ import pytest
from sqlalchemy import Engine, delete, select
from sqlalchemy.orm import Session, sessionmaker
from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormStatus
from core.workflow.nodes.human_input.entities import FormDefinition, UserActionConfig
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
from libs.datetime_utils import naive_utc_now
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import ConversationFromSource, InvokeFrom
@@ -16,9 +16,9 @@ from core.workflow.human_input_adapter import (
EmailRecipients,
ExternalRecipient,
)
from core.workflow.nodes.human_input.entities import FileInputConfig, HumanInputNodeData
from core.workflow.nodes.human_input.enums import HumanInputFormKind
from graphon.enums import BuiltinNodeTypes
from graphon.nodes.human_input.entities import FileInputConfig, HumanInputNodeData
from graphon.nodes.human_input.enums import HumanInputFormKind
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.human_input import HumanInputForm, HumanInputFormRecipient, HumanInputFormUploadFile
from models.model import App, AppMode, UploadFile
@@ -7,8 +7,8 @@ from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
import services.human_input_file_upload_service as service_module
from core.workflow.nodes.human_input.enums import HumanInputFormKind
from extensions.ext_database import db
from graphon.nodes.human_input.enums import HumanInputFormKind
from libs.datetime_utils import naive_utc_now
from models.human_input import (
HumanInputForm,
@@ -12,10 +12,10 @@ from sqlalchemy.orm import Session, sessionmaker
from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from graphon.entities.pause_reason import HumanInputRequired
from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource, UserActionConfig
from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType
from graphon.runtime import GraphRuntimeState, VariablePool
from models.enums import CreatorUserRole
from models.human_input import HumanInputForm
@@ -18,9 +18,9 @@ from core.workflow.human_input_adapter import (
ExternalRecipient,
MemberRecipient,
)
from core.workflow.nodes.human_input.entities import HumanInputNodeData
from extensions.ext_storage import storage
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.entities import HumanInputNodeData
from graphon.runtime import GraphRuntimeState, VariablePool
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
@@ -10,9 +10,9 @@ from flask import Flask
from controllers.common.errors import NotFoundError
from controllers.console.app import workflow_run as workflow_run_module
from graphon.entities.pause_reason import HumanInputRequired
from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
from models.workflow import WorkflowRun
@@ -30,12 +30,12 @@ from core.app.entities.task_entities import (
)
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
from core.workflow.nodes.human_input.enums import FormInputType
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType, HumanInputRequired
from core.workflow.system_variables import build_system_variables
from graphon.entities import WorkflowStartReason
from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
from graphon.nodes.human_input.enums import FormInputType
from graphon.runtime import GraphRuntimeState, VariablePool
from models.account import Account
from models.enums import CreatorUserRole
@@ -115,7 +115,7 @@ def _build_advanced_chat_paused_blocking_response() -> AdvancedChatPausedBlockin
paused_nodes=["node-1"],
reasons=[
{
"type": PauseReasonType.HUMAN_INPUT_REQUIRED,
"type": DifyHITLEventType.HUMAN_INPUT_REQUIRED.value,
"form_id": "form-1",
"expiration_time": 100,
}
@@ -384,7 +384,7 @@ class TestHitlServiceApi:
assert response["event"] == "workflow_paused"
assert response["workflow_run_id"] == "run-1"
assert response["answer"] == "partial"
assert response["data"]["reasons"][0]["type"] == PauseReasonType.HUMAN_INPUT_REQUIRED
assert response["data"]["reasons"][0]["type"] == DifyHITLEventType.HUMAN_INPUT_REQUIRED
assert response["data"]["reasons"][0]["expiration_time"] == 100
assert "human_input_forms" not in response["data"]
@@ -476,7 +476,7 @@ class TestHitlServiceApi:
outputs={},
reasons=[
{
"type": PauseReasonType.HUMAN_INPUT_REQUIRED,
"type": DifyHITLEventType.HUMAN_INPUT_REQUIRED.value,
"form_id": "form-1",
"node_id": "node-1",
"expiration_time": 123,
@@ -15,8 +15,8 @@ from werkzeug.exceptions import Forbidden
import controllers.web.human_input_form as human_input_module
import controllers.web.site as site_module
from controllers.web.error import WebFormRateLimitExceededError
from graphon.nodes.human_input.entities import ParagraphInputConfig, SelectInputConfig, StringListSource
from graphon.nodes.human_input.enums import ValueSourceType
from core.workflow.nodes.human_input.entities import ParagraphInputConfig, SelectInputConfig, StringListSource
from core.workflow.nodes.human_input.enums import ValueSourceType
from models.human_input import RecipientType
from services.feature_service import FeatureModel
from services.human_input_service import FormExpiredError
@@ -13,7 +13,7 @@ from core.app.entities.task_entities import (
NodeStartStreamResponse,
PingStreamResponse,
)
from graphon.entities.pause_reason import PauseReasonType
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
@@ -43,7 +43,7 @@ class TestAdvancedChatGenerateResponseConverter:
metadata={"usage": {"total_tokens": 1}},
created_at=1,
paused_nodes=["node-1"],
reasons=[{"type": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "form-1"}],
reasons=[{"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, "form_id": "form-1"}],
status=WorkflowExecutionStatus.PAUSED,
elapsed_time=0.1,
total_tokens=0,
@@ -17,7 +17,7 @@ from core.app.entities.queue_entities import (
QueueWorkflowSucceededEvent,
)
from core.app.entities.task_entities import StreamEvent
from graphon.entities.pause_reason import HumanInputRequired
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from models.enums import MessageStatus
from models.execution_extra_content import HumanInputContent
@@ -50,10 +50,10 @@ from core.app.entities.task_entities import (
ReasoningChunkStreamResponse,
)
from core.base.tts.app_generator_tts_publisher import AudioTrunk
from core.workflow.nodes.human_input.entities import UserActionConfig
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType
from core.workflow.system_variables import build_system_variables
from graphon.entities.pause_reason import PauseReasonType
from graphon.enums import BuiltinNodeTypes
from graphon.nodes.human_input.entities import UserActionConfig
from graphon.runtime import GraphRuntimeState, VariablePool
from libs.datetime_utils import naive_utc_now
from models.enums import MessageStatus
@@ -168,7 +168,7 @@ class TestAdvancedChatGenerateTaskPipeline:
assert response.data.paused_nodes == ["node-1"]
assert response.data.reasons == [
{
"TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED,
"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED,
"form_id": "form-1",
"node_id": "node-1",
"node_title": "Approval",
@@ -5,7 +5,7 @@ from typing import Any
from pytest_mock import MockerFixture
import graphon.nodes.human_input.entities # noqa: F401
import core.workflow.nodes.human_input.entities # noqa: F401
from core.app.apps.advanced_chat import app_generator as adv_app_gen_module
from core.app.apps.workflow import app_generator as wf_app_gen_module
from core.app.entities.app_invoke_entities import InvokeFrom
@@ -22,8 +22,9 @@ from core.app.entities.queue_entities import (
QueueWorkflowStartedEvent,
QueueWorkflowSucceededEvent,
)
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from core.workflow.system_variables import default_system_variables
from graphon.entities.pause_reason import HumanInputRequired
from graphon.entities.pause_reason import HitlRequired
from graphon.enums import BuiltinNodeTypes
from graphon.graph_events import (
GraphRunPausedEvent,
@@ -344,10 +345,20 @@ class TestWorkflowBasedAppRunner:
"core.app.apps.workflow_app_runner.dispatch_human_input_email_task",
_Dispatch(),
)
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.enrich_graph_pause_reasons",
lambda **_: [
HumanInputRequired(
form_id="form",
form_content="content",
node_id="node-1",
node_title="Node",
)
],
)
reason = HumanInputRequired(
form_id="form",
form_content="content",
reason = HitlRequired(
session_id="form",
node_id="node-1",
node_title="Node",
)
@@ -4,7 +4,8 @@ import pytest
from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
from core.app.entities.queue_entities import QueueWorkflowPausedEvent
from graphon.entities.pause_reason import HumanInputRequired
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.entities.pause_reason import HitlRequired
from graphon.graph_events import GraphRunPausedEvent
@@ -17,6 +18,8 @@ class _DummyQueueManager:
class _DummyRuntimeState:
variable_pool = object()
def get_paused_nodes(self):
return ["node-1"]
@@ -36,7 +39,11 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch)
runner = WorkflowBasedAppRunner(queue_manager=queue_manager, app_id="app-id")
workflow_entry = _DummyWorkflowEntry()
reason = HumanInputRequired(
graph_reason = HitlRequired(session_id="form-123", node_id="node-1", node_title="Review")
event = GraphRunPausedEvent(reasons=[graph_reason], outputs={})
email_task = MagicMock()
enriched_reason = HumanInputRequired(
form_id="form-123",
form_content="content",
inputs=[],
@@ -44,9 +51,10 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch)
node_id="node-1",
node_title="Review",
)
event = GraphRunPausedEvent(reasons=[reason], outputs={})
email_task = MagicMock()
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.enrich_graph_pause_reasons",
lambda **_: [enriched_reason],
)
monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_email_task", email_task)
runner._handle_event(workflow_entry, event)
@@ -10,17 +10,18 @@ from core.app.apps.workflow.app_runner import WorkflowAppRunner
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueWorkflowPausedEvent
from core.app.entities.task_entities import HumanInputRequiredResponse, WorkflowPauseStreamResponse
from core.workflow.system_variables import build_system_variables
from graphon.entities import WorkflowStartReason
from graphon.entities.pause_reason import HumanInputRequired
from graphon.graph_events import GraphRunPausedEvent
from graphon.nodes.human_input.entities import (
from core.workflow.nodes.human_input.entities import (
ParagraphInputConfig,
SelectInputConfig,
StringListSource,
UserActionConfig,
)
from graphon.nodes.human_input.enums import ValueSourceType
from core.workflow.nodes.human_input.enums import ValueSourceType
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from core.workflow.system_variables import build_system_variables
from graphon.entities import WorkflowStartReason
from graphon.entities.pause_reason import HitlRequired
from graphon.graph_events import GraphRunPausedEvent
from graphon.runtime import GraphRuntimeState, VariablePool
from models.account import Account
from models.human_input import RecipientType
@@ -56,6 +57,8 @@ class _RecordingWorkflowAppRunner(WorkflowAppRunner):
class _FakeRuntimeState:
variable_pool = object()
def get_paused_nodes(self):
return ["node-pause-1"]
@@ -92,9 +95,19 @@ def _build_runner():
)
def test_graph_run_paused_event_emits_queue_pause_event():
def test_graph_run_paused_event_emits_queue_pause_event(monkeypatch: pytest.MonkeyPatch):
runner = _build_runner()
reason = HumanInputRequired(
graph_reason = HitlRequired(
session_id="form-1",
node_id="node-human",
node_title="Human Step",
)
event = GraphRunPausedEvent(reasons=[graph_reason], outputs={"foo": "bar"})
workflow_entry = SimpleNamespace(
graph_engine=SimpleNamespace(graph_runtime_state=_FakeRuntimeState()),
)
enriched_reason = HumanInputRequired(
form_id="form-1",
form_content="content",
inputs=[],
@@ -102,9 +115,9 @@ def test_graph_run_paused_event_emits_queue_pause_event():
node_id="node-human",
node_title="Human Step",
)
event = GraphRunPausedEvent(reasons=[reason], outputs={"foo": "bar"})
workflow_entry = SimpleNamespace(
graph_engine=SimpleNamespace(graph_runtime_state=_FakeRuntimeState()),
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.enrich_graph_pause_reasons",
lambda **_: [enriched_reason],
)
runner._handle_event(workflow_entry, event)
@@ -112,7 +125,7 @@ def test_graph_run_paused_event_emits_queue_pause_event():
assert len(runner.published_events) == 1
queue_event = runner.published_events[0]
assert isinstance(queue_event, QueueWorkflowPausedEvent)
assert queue_event.reasons == [reason]
assert queue_event.reasons == [enriched_reason]
assert queue_event.outputs == {"foo": "bar"}
assert queue_event.paused_nodes == ["node-pause-1"]
@@ -7,14 +7,16 @@ import pytest
from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
from core.app.layers import pause_state_persist_layer as pause_layer_module
from core.app.layers.pause_state_persist_layer import (
PauseStatePersistenceLayer,
WorkflowResumptionContext,
_AdvancedChatAppGenerateEntityWrapper,
_WorkflowGenerateEntityWrapper,
)
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from core.workflow.system_variables import SystemVariableKey
from graphon.entities.pause_reason import SchedulingPause
from graphon.entities.pause_reason import HitlRequired, SchedulingPause
from graphon.graph_engine.entities.commands import GraphEngineCommand
from graphon.graph_engine.layers.base import GraphEngineLayerNotInitializedError
from graphon.graph_events import (
@@ -263,6 +265,63 @@ class TestPauseStatePersistenceLayer:
assert isinstance(pause_reasons, list)
def test_on_event_enriches_hitl_pause_reasons_before_persisting(self, monkeypatch: pytest.MonkeyPatch):
session_factory = Mock(name="session_factory")
generate_entity = self._create_generate_entity(workflow_execution_id="run-123")
layer = PauseStatePersistenceLayer(
session_factory=session_factory,
state_owner_user_id="owner-123",
generate_entity=generate_entity,
)
mock_repo = Mock()
mock_factory = Mock(return_value=mock_repo)
mock_form_repository = Mock(name="form_repository")
enriched_reason = HumanInputRequired(
form_id="form-123",
form_content="Rendered content",
inputs=[],
actions=[],
node_id="node-123",
node_title="Ask for approval",
)
enrich_mock = Mock(return_value=[enriched_reason])
monkeypatch.setattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository", mock_factory)
monkeypatch.setattr(
pause_layer_module,
"HumanInputFormSubmissionRepository",
Mock(return_value=mock_form_repository),
raising=False,
)
monkeypatch.setattr(
pause_layer_module,
"enrich_graph_pause_reasons",
enrich_mock,
raising=False,
)
graph_runtime_state = MockReadOnlyGraphRuntimeState(
workflow_execution_id="run-123",
)
command_channel = MockCommandChannel()
layer.initialize(graph_runtime_state, command_channel)
raw_reason = HitlRequired(
session_id="session-123",
node_id="node-123",
node_title="Ask for approval",
)
event = GraphRunPausedEvent(reasons=[raw_reason], outputs={})
layer.on_event(event)
enrich_mock.assert_called_once_with(
reasons=[raw_reason],
form_repository=mock_form_repository,
variable_pool=graph_runtime_state.variable_pool,
)
assert mock_repo.create_workflow_pause.call_args.kwargs["pause_reasons"] == [enriched_reason]
def test_on_event_ignores_non_paused_events(self, monkeypatch: pytest.MonkeyPatch):
session_factory = Mock(name="session_factory")
layer = PauseStatePersistenceLayer(
@@ -4,7 +4,7 @@ from core.entities.execution_extra_content import (
HumanInputFormDefinition,
HumanInputFormSubmissionData,
)
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
from models.execution_extra_content import ExecutionContentType
@@ -21,11 +21,11 @@ from core.workflow.human_input_adapter import (
ExternalRecipient,
MemberRecipient,
)
from graphon.nodes.human_input.entities import (
from core.workflow.nodes.human_input.entities import (
FormDefinition,
UserActionConfig,
)
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from libs.datetime_utils import naive_utc_now
from models.human_input import (
EmailExternalRecipientPayload,
@@ -29,8 +29,8 @@ from core.workflow.human_input_adapter import (
MemberRecipient,
WebAppDeliveryMethod,
)
from graphon.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from libs.datetime_utils import naive_utc_now
from models.human_input import HumanInputFormRecipient, RecipientType
@@ -10,6 +10,18 @@ from core.repositories.human_input_repository import (
HumanInputFormRepository,
)
from core.workflow.node_runtime import DifyHumanInputNodeRuntime
from core.workflow.nodes.human_input.callback import (
DifyHITLCallback,
)
from core.workflow.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
HumanInputNodeData,
SelectInputConfig,
StringListSource,
UserActionConfig,
)
from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType
from core.workflow.system_variables import build_system_variables
from graphon.entities import WorkflowStartReason
from graphon.file import File, FileTransferMethod, FileType
@@ -25,15 +37,6 @@ from graphon.graph_events import (
from graphon.nodes.base.entities import OutputVariableEntity
from graphon.nodes.end.end_node import EndNode
from graphon.nodes.end.entities import EndNodeData
from graphon.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
HumanInputNodeData,
SelectInputConfig,
StringListSource,
UserActionConfig,
)
from graphon.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType
from graphon.nodes.human_input.human_input_node import HumanInputNode
from graphon.nodes.start.entities import StartNodeData
from graphon.nodes.start.start_node import StartNode
@@ -83,6 +86,7 @@ class StaticForm(HumanInputFormEntity):
action_id: str | None = None
data: Mapping[str, Any] | None = None
status_value: HumanInputFormStatus = HumanInputFormStatus.WAITING
created: datetime = naive_utc_now()
expiration: datetime = naive_utc_now() + timedelta(days=1)
@property
@@ -105,6 +109,10 @@ class StaticForm(HumanInputFormEntity):
def selected_action_id(self) -> str | None:
return self.action_id
@property
def created_at(self) -> datetime:
return self.created
@property
def submitted_data(self) -> Mapping[str, Any] | None:
return self.data
@@ -188,27 +196,33 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor
human_a_config = {"id": "human_a", "data": human_data.model_dump()}
human_a_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
human_a_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
human_a_callback = DifyHITLCallback(
form_repository=repo,
node_data=human_data,
file_reference_factory=_TestFileReferenceFactory(),
)
human_a = HumanInputNode(
node_id=human_a_config["id"],
data=human_data,
graph_init_params=graph_init_params,
graph_runtime_state=runtime_state,
form_repository=repo,
file_reference_factory=_TestFileReferenceFactory(),
runtime=human_a_runtime,
hitl_callback=human_a_callback,
)
human_b_config = {"id": "human_b", "data": human_data.model_dump()}
human_b_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
human_b_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
human_b_callback = DifyHITLCallback(
form_repository=repo,
node_data=human_data,
file_reference_factory=_TestFileReferenceFactory(),
)
human_b = HumanInputNode(
node_id=human_b_config["id"],
data=human_data,
graph_init_params=graph_init_params,
graph_runtime_state=runtime_state,
form_repository=repo,
file_reference_factory=_TestFileReferenceFactory(),
runtime=human_b_runtime,
hitl_callback=human_b_callback,
)
end_data = EndNodeData(
@@ -27,12 +27,12 @@ from core.workflow.nodes.agent_v2.session_store import (
WorkflowAgentRuntimeSessionStore,
WorkflowAgentSessionScope,
)
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.entities import GraphInitParams
from graphon.entities.pause_reason import HumanInputRequired
from graphon.entities.pause_reason import HitlRequired
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from graphon.file import File, FileTransferMethod, FileType
from graphon.node_events import PauseRequestedEvent, StreamCompletedEvent
from graphon.nodes.human_input.entities import UserActionConfig
from graphon.runtime import GraphRuntimeState
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
@@ -510,7 +510,7 @@ def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot():
node = _node(scenario=FakeAgentBackendScenario.PAUSED, session_store=store)
# ENG-636: the PAUSED scenario emits a dify.ask_human deferred call, so the
# node now builds a HITL form and pauses with HumanInputRequired. Stub the
# node now builds a HITL form and pauses with HitlRequired. Stub the
# form repository so the unit test stays DB-free.
fake_repo = MagicMock()
fake_repo.create_form.return_value = MagicMock(id="form-1")
@@ -520,8 +520,8 @@ def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot():
assert len(events) == 1
assert isinstance(events[0], PauseRequestedEvent)
assert isinstance(events[0].reason, HumanInputRequired)
assert events[0].reason.form_id == "form-1"
assert isinstance(events[0].reason, HitlRequired)
assert events[0].reason.session_id == "form-1"
assert events[0].reason.node_id == "agent-node"
fake_repo.create_form.assert_called_once()
assert store.saved
@@ -585,7 +585,7 @@ def test_agent_node_repauses_when_resumed_form_still_waiting(monkeypatch):
form_id="form-1",
form_content="Approve?",
inputs=[],
actions=[UserActionConfig(id="ok", title="OK")],
actions=[],
node_id="agent-node",
node_title="Budget review",
)
@@ -601,7 +601,7 @@ def test_agent_node_repauses_when_resumed_form_still_waiting(monkeypatch):
assert len(events) == 1
assert isinstance(events[0], PauseRequestedEvent)
assert isinstance(events[0].reason, HumanInputRequired)
assert isinstance(events[0].reason, HitlRequired)
assert client.request is None # no second Agent run was created
@@ -22,13 +22,14 @@ from core.workflow.nodes.agent_v2.ask_human_hitl import (
build_delivery_methods,
parse_ask_human_args,
)
from graphon.nodes.human_input.entities import (
from core.workflow.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
ParagraphInputConfig,
SelectInputConfig,
)
from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit
from core.workflow.nodes.human_input.enums import ButtonStyle, TimeoutUnit
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from models.agent_config_entities import AgentHumanContactConfig
@@ -229,7 +230,7 @@ def test_pause_reason_requires_workflow_run_id() -> None:
)
def test_pause_reason_builds_form_and_returns_human_input_required() -> None:
def test_pause_reason_builds_form_and_returns_dify_pause_reason() -> None:
repo = _fake_repository(form_id="form-xyz")
contacts = [AgentHumanContactConfig(email="a@x.com")]
@@ -251,6 +252,7 @@ def test_pause_reason_builds_form_and_returns_human_input_required() -> None:
assert result is not None
assert result.form_id == "form-xyz"
assert isinstance(result, HumanInputRequired)
assert result.node_id == "node-1"
assert result.node_title == "Approve?" # args.title wins over default
assert [i.output_variable_name for i in result.inputs] == ["note"]
@@ -321,6 +323,7 @@ def test_pause_reason_select_default_flows_into_resolved_defaults() -> None:
repository=repo,
)
assert result is not None
params: FormCreateParams = repo.create_form.call_args.args[0]
assert result.resolved_default_values == {"tier": "t1"}
@@ -4,15 +4,16 @@ from __future__ import annotations
from datetime import datetime
import pytest
from dify_agent.layers.ask_human import AskHumanToolResult
from core.workflow.nodes.agent_v2.ask_human_resume import (
build_deferred_tool_results,
map_form_to_outcome,
)
from graphon.entities.pause_reason import HumanInputRequired
from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormStatus
from core.workflow.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
def _form_definition_json() -> str:
@@ -64,18 +65,17 @@ def test_map_timeout_form_to_timeout_result() -> None:
assert outcome.deferred_result.action is None
def test_map_expired_form_to_timeout_result() -> None:
outcome = map_form_to_outcome(
status=HumanInputFormStatus.EXPIRED,
selected_action_id=None,
submitted_data=None,
rendered_content="x",
form_definition=_form_definition_json(),
form_id="form-1",
node_id="node-1",
)
assert outcome.deferred_result is not None
assert outcome.deferred_result.status == "timeout"
def test_map_expired_form_rejects_invalid_resume_state() -> None:
with pytest.raises(AssertionError, match="globally expired ask_human form"):
map_form_to_outcome(
status=HumanInputFormStatus.EXPIRED,
selected_action_id=None,
submitted_data=None,
rendered_content="x",
form_definition=_form_definition_json(),
form_id="form-1",
node_id="node-1",
)
def test_map_waiting_form_rebuilds_pause() -> None:
@@ -0,0 +1,139 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
import pytest
from pydantic import TypeAdapter
from core.workflow.nodes.human_input._exc import ExtensionsNotSetErrorValueError
from core.workflow.nodes.human_input.entities import (
FileInputConfig,
FormDefinition,
ParagraphInputConfig,
SelectInputConfig,
UserActionConfig,
)
from core.workflow.nodes.human_input.enums import ButtonStyle, FormInputType, ValueSourceType
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from core.workflow.nodes.human_input.session_binding import SessionBinding
from graphon.file import FileType
def test_session_binding_identity_mapping() -> None:
binding = SessionBinding()
assert binding.issue_session_id_for_form(form_id="form-1") == "form-1"
assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1"
def test_human_input_node_contracts_accept_legacy_json_payload() -> None:
payload = {
"form_content": "Please confirm",
"inputs": [
{
"type": "paragraph",
"output_variable_name": "name",
"default": {
"type": "constant",
"selector": [],
"value": "Alice",
},
},
{
"type": "select",
"output_variable_name": "decision",
"option_source": {
"type": "constant",
"selector": [],
"value": ["approve", "reject"],
},
},
],
"user_actions": [
{
"id": "approve",
"title": "Approve",
"button_style": "primary",
}
],
"rendered_content": "Please confirm",
"expiration_time": "2024-01-01T00:00:00Z",
"default_values": {"name": "Alice"},
"node_title": "Human Input",
"display_in_ui": True,
}
restored = TypeAdapter(FormDefinition).validate_json(json.dumps(payload))
assert isinstance(restored.inputs[0], ParagraphInputConfig)
assert isinstance(restored.inputs[1], SelectInputConfig)
assert restored.inputs[0].default is not None
assert restored.inputs[0].default.type == ValueSourceType.CONSTANT
assert restored.inputs[0].default.value == "Alice"
assert restored.inputs[1].option_source.type == ValueSourceType.CONSTANT
assert restored.inputs[1].option_source.value == ["approve", "reject"]
assert restored.user_actions == [UserActionConfig(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY)]
def test_human_input_required_pause_reason_keeps_legacy_payload_shape() -> None:
payload = {
"TYPE": "human_input_required",
"form_id": "form-1",
"form_content": "Please confirm",
"inputs": [
{
"type": "paragraph",
"output_variable_name": "name",
"default": {
"type": "constant",
"selector": [],
"value": "Alice",
},
}
],
"actions": [
{
"id": "approve",
"title": "Approve",
"button_style": "primary",
}
],
"node_id": "node-1",
"node_title": "Human Input",
"resolved_default_values": {"name": "Alice"},
}
restored = TypeAdapter(HumanInputRequired).validate_json(json.dumps(payload))
assert restored.TYPE.value == "human_input_required"
assert restored.actions[0].button_style == ButtonStyle.PRIMARY
assert restored.model_dump(mode="json")["TYPE"] == "human_input_required"
def test_form_definition_dump_keeps_public_json_shape() -> None:
definition = FormDefinition(
form_content="Please confirm",
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
user_actions=[UserActionConfig(id="approve", title="Approve")],
rendered_content="rendered",
expiration_time=datetime(2024, 1, 1, tzinfo=UTC),
default_values={"name": "Alice"},
node_title="Ask Name",
display_in_ui=True,
)
payload = definition.model_dump(mode="json")
assert payload["expiration_time"] == "2024-01-01T00:00:00Z"
assert payload["user_actions"][0]["id"] == "approve"
assert payload["inputs"][0]["type"] == "paragraph"
def test_custom_file_input_requires_extensions() -> None:
with pytest.raises(ExtensionsNotSetErrorValueError):
FileInputConfig(
output_variable_name="attachment",
allowed_file_types=[FileType.CUSTOM],
allowed_file_extensions=[],
)
@@ -31,12 +31,10 @@ from core.workflow.human_input_adapter import (
_WebAppDeliveryConfig,
)
from core.workflow.node_runtime import DifyHumanInputNodeRuntime
from core.workflow.system_variables import build_system_variables
from graphon.entities import GraphInitParams
from graphon.file import File, FileTransferMethod, FileType
from graphon.node_events import PauseRequestedEvent
from graphon.node_events.node import StreamCompletedEvent
from graphon.nodes.human_input.entities import (
from core.workflow.nodes.human_input.callback import (
DifyHITLCallback,
)
from core.workflow.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
HumanInputNodeData,
@@ -46,13 +44,18 @@ from graphon.nodes.human_input.entities import (
StringSource,
UserActionConfig,
)
from graphon.nodes.human_input.enums import (
from core.workflow.nodes.human_input.enums import (
ButtonStyle,
FormInputType,
HumanInputFormStatus,
TimeoutUnit,
ValueSourceType,
)
from core.workflow.system_variables import build_system_variables
from graphon.entities import GraphInitParams
from graphon.file import File, FileTransferMethod, FileType
from graphon.node_events import PauseRequestedEvent
from graphon.node_events.node import StreamCompletedEvent
from graphon.nodes.human_input.human_input_node import HumanInputNode
from graphon.nodes.protocols import FileReferenceFactoryProtocol
from graphon.runtime import GraphRuntimeState, VariablePool
@@ -69,6 +72,7 @@ class _InMemoryFormEntity(HumanInputFormEntity):
data: Mapping[str, Any] | None = None
is_submitted: bool = False
status_value: HumanInputFormStatus = HumanInputFormStatus.WAITING
created: datetime = field(default_factory=naive_utc_now)
expiration: datetime = field(default_factory=lambda: naive_utc_now() + timedelta(days=1))
@property
@@ -91,6 +95,10 @@ class _InMemoryFormEntity(HumanInputFormEntity):
def selected_action_id(self) -> str | None:
return self.action_id
@property
def created_at(self) -> datetime:
return self.created
@property
def submitted_data(self) -> Mapping[str, Any] | None:
return self.data
@@ -172,13 +180,19 @@ def _build_human_input_node(
node_data if isinstance(node_data, HumanInputNodeData) else HumanInputNodeData.model_validate(node_data)
)
runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
callback = DifyHITLCallback(
form_repository=runtime.build_form_repository(),
node_data=typed_node_data,
delivery_methods=runtime._resolve_delivery_methods(node_data=typed_node_data),
display_in_ui=runtime._display_in_ui(node_data=typed_node_data),
file_reference_factory=_TestFileReferenceFactory(),
)
return HumanInputNode(
node_id=node_id,
data=typed_node_data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
file_reference_factory=_TestFileReferenceFactory(),
runtime=runtime,
hitl_callback=callback,
)
@@ -523,7 +537,8 @@ class TestHumanInputNodeVariableResolution:
assert isinstance(pause_event, PauseRequestedEvent)
expected_values = {"user_name": "Jane Doe"}
assert pause_event.reason.resolved_default_values == expected_values
create_params = mock_repo.create_form.call_args.args[0]
assert create_params.resolved_default_values == expected_values
params = mock_repo.create_form.call_args.args[0]
assert params.resolved_default_values == expected_values
@@ -4,17 +4,10 @@ from types import SimpleNamespace
from typing import Any
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom
from core.workflow.node_runtime import DifyHumanInputNodeRuntime
from core.workflow.system_variables import default_system_variables
from graphon.entities import GraphInitParams
from graphon.enums import BuiltinNodeTypes
from graphon.file import File, FileTransferMethod, FileType
from graphon.graph_events import (
NodeRunHumanInputFormFilledEvent,
NodeRunHumanInputFormTimeoutEvent,
NodeRunStartedEvent,
from core.workflow.nodes.human_input.callback import (
DifyHITLCallback,
)
from graphon.nodes.human_input.entities import (
from core.workflow.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
HumanInputNodeData,
@@ -23,7 +16,15 @@ from graphon.nodes.human_input.entities import (
StringListSource,
UserActionConfig,
)
from graphon.nodes.human_input.enums import HumanInputFormStatus
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
from core.workflow.system_variables import default_system_variables
from graphon.entities import GraphInitParams
from graphon.enums import BuiltinNodeTypes
from graphon.file import File, FileTransferMethod, FileType
from graphon.graph_events import (
NodeRunStartedEvent,
NodeRunSucceededEvent,
)
from graphon.nodes.human_input.human_input_node import HumanInputNode
from graphon.nodes.protocols import FileReferenceFactoryProtocol
from graphon.runtime import GraphRuntimeState, VariablePool
@@ -67,16 +68,17 @@ def _create_human_input_node(
if isinstance(config["data"], HumanInputNodeData)
else HumanInputNodeData.model_validate(config["data"])
)
runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
callback = DifyHITLCallback(
form_repository=repo,
node_data=node_data,
file_reference_factory=_TestFileReferenceFactory(),
)
return HumanInputNode(
node_id=config["id"],
data=node_data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
form_repository=repo,
file_reference_factory=_TestFileReferenceFactory(),
runtime=runtime,
hitl_callback=callback,
)
@@ -232,26 +234,25 @@ def test_human_input_node_emits_form_filled_event_before_succeeded():
events = list(node.run())
assert isinstance(events[0], NodeRunStartedEvent)
assert isinstance(events[1], NodeRunHumanInputFormFilledEvent)
assert isinstance(events[1], NodeRunSucceededEvent)
filled_event = events[1]
assert filled_event.node_title == "Human Input"
assert filled_event.rendered_content == (
"Please enter your name:\n\nAlice\nDecision: approve\nAttachment: [file]\nAttachments: [1 files]"
completed_event = events[1]
assert completed_event.node_run_result.outputs["__rendered_content"] == StringSegment(
value="Please enter your name:\n\nAlice\nDecision: approve\nAttachment: [file]\nAttachments: [1 files]"
)
assert filled_event.action_id == "Accept"
assert filled_event.action_text == "Approve"
assert filled_event.submitted_data["name"] == StringSegment(value="Alice")
assert filled_event.submitted_data["decision"] == StringSegment(value="approve")
assert isinstance(filled_event.submitted_data["attachment"], FileSegment)
assert filled_event.submitted_data["attachment"].value_type == SegmentType.FILE
assert filled_event.submitted_data["attachment"].value.filename == "resume.pdf"
assert filled_event.submitted_data["attachment"].value.type == FileType.DOCUMENT
assert filled_event.submitted_data["attachment"].value.transfer_method == FileTransferMethod.REMOTE_URL
assert isinstance(filled_event.submitted_data["attachments"], ArrayFileSegment)
assert filled_event.submitted_data["attachments"].value_type == SegmentType.ARRAY_FILE
assert filled_event.submitted_data["attachments"].value[0].filename == "a.png"
assert filled_event.submitted_data["attachments"].value[0].type == FileType.IMAGE
assert completed_event.node_run_result.outputs["__action_id"] == StringSegment(value="Accept")
assert completed_event.node_run_result.outputs["__action_value"] == StringSegment(value="Approve")
assert completed_event.node_run_result.inputs["name"] == StringSegment(value="Alice")
assert completed_event.node_run_result.inputs["decision"] == StringSegment(value="approve")
assert isinstance(completed_event.node_run_result.inputs["attachment"], FileSegment)
assert completed_event.node_run_result.inputs["attachment"].value_type == SegmentType.FILE
assert completed_event.node_run_result.inputs["attachment"].value.filename == "resume.pdf"
assert completed_event.node_run_result.inputs["attachment"].value.type == FileType.DOCUMENT
assert completed_event.node_run_result.inputs["attachment"].value.transfer_method == FileTransferMethod.REMOTE_URL
assert isinstance(completed_event.node_run_result.inputs["attachments"], ArrayFileSegment)
assert completed_event.node_run_result.inputs["attachments"].value_type == SegmentType.ARRAY_FILE
assert completed_event.node_run_result.inputs["attachments"].value[0].filename == "a.png"
assert completed_event.node_run_result.inputs["attachments"].value[0].type == FileType.IMAGE
def test_human_input_node_emits_timeout_event_before_succeeded():
@@ -260,7 +261,5 @@ def test_human_input_node_emits_timeout_event_before_succeeded():
events = list(node.run())
assert isinstance(events[0], NodeRunStartedEvent)
assert isinstance(events[1], NodeRunHumanInputFormTimeoutEvent)
timeout_event = events[1]
assert timeout_event.node_title == "Human Input"
assert isinstance(events[1], NodeRunSucceededEvent)
assert events[1].node_run_result.edge_source_handle == "__timeout__"
@@ -242,7 +242,7 @@ def test_extract_text_from_pdf(mock_pdf_document):
mock_text_page = Mock()
mock_text_page.get_text_range.return_value = "PDF content"
mock_page.get_textpage.return_value = mock_text_page
mock_pdf_document.return_value = [mock_page]
mock_pdf_document.return_value.__enter__.return_value = [mock_page]
text = _extract_text_from_pdf(b"%PDF-1.5\n%Test PDF content")
assert text == "PDF content"
@@ -1,9 +1,14 @@
from unittest.mock import Mock
import pytest
from core.repositories.human_input_repository import HumanInputFormSubmissionRepository
from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons
from graphon.entities.pause_reason import PauseReasonType
from core.workflow.nodes.human_input.boundary import enrich_graph_pause_reasons
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType
from graphon.entities.pause_reason import HitlRequired
_HUMAN_INPUT_REASON = {"TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "f1"}
_HUMAN_INPUT_REASON = {"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, "form_id": "f1"}
@pytest.mark.parametrize(
@@ -61,3 +66,21 @@ def test_pause_reason_payload_carries_approval_channels_through_factory():
assert payload.approval_channels == ["console"]
assert payload.form_token is None
def test_enrich_graph_pause_reasons_raises_when_hitl_form_record_is_missing():
form_repository = Mock(spec=HumanInputFormSubmissionRepository)
form_repository.get_by_form_id.return_value = None
with pytest.raises(LookupError, match="form-123"):
enrich_graph_pause_reasons(
reasons=[
HitlRequired(
session_id="form-123",
node_id="node-1",
node_title="Ask Name",
)
],
form_repository=form_repository,
variable_pool=None,
)
@@ -8,13 +8,14 @@ from core.entities.execution_extra_content import (
HumanInputContent,
HumanInputFormDefinition,
)
from graphon.entities.pause_reason import HumanInputRequired
from graphon.nodes.human_input.entities import (
from core.workflow.nodes.human_input.entities import (
FormDefinition,
FormInputConfig,
HumanInputNodeData,
)
from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType
from core.workflow.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from core.workflow.nodes.human_input.session_binding import SessionBinding
def _legacy_form_input_payloads() -> list[dict[str, Any]]:
@@ -336,3 +337,10 @@ def test_human_input_required_response_accepts_current_serialized_payload() -> N
assert restored.data.inputs[1].output_variable_name == "decision"
assert restored.data.actions[0].id == "approve"
assert restored.event == "human_input_required"
def test_session_binding_identity_mapping() -> None:
binding = SessionBinding()
assert binding.issue_session_id_for_form(form_id="form-1") == "form-1"
assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1"
@@ -0,0 +1,188 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepository
from core.workflow.nodes.human_input.callback import DifyHITLCallback
from core.workflow.nodes.human_input.entities import HumanInputNodeData, ParagraphInputConfig, UserActionConfig
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
from core.workflow.nodes.human_input.session_binding import SessionBinding
from graphon.runtime import VariablePool
from graphon.variables.factory import build_segment
from libs.datetime_utils import naive_utc_now
@dataclass(frozen=True, slots=True)
class _Context:
workflow_execution_id: str
node_id: str
node_title: str = "Human Input"
variable_pool: VariablePool = field(default_factory=VariablePool)
def _ctx(workflow_execution_id: str, node_id: str, node_title: str = "Human Input") -> _Context:
return _Context(workflow_execution_id=workflow_execution_id, node_id=node_id, node_title=node_title)
def test_session_binding_identity_mapping() -> None:
binding = SessionBinding()
assert binding.issue_session_id_for_form(form_id="form-1") == "form-1"
assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1"
def test_dify_hitl_callback_creates_pause_requested_for_new_form() -> None:
repository = MagicMock(spec=HumanInputFormRepository)
repository.get_form.return_value = None
repository.create_form.return_value = SimpleNamespace(id="form-1")
callback = DifyHITLCallback(
form_repository=repository,
node_data=HumanInputNodeData(
title="Approval",
form_content="Please approve",
inputs=[ParagraphInputConfig(output_variable_name="answer")],
user_actions=[UserActionConfig(id="approve", title="Approve")],
),
workflow_execution_id="run-1",
)
decision = callback(_ctx("run-1", "node-1"))
assert decision == callback.pause_requested_type(session_id="form-1")
params: FormCreateParams = repository.create_form.call_args.args[0]
assert params.workflow_execution_id == "run-1"
assert params.node_id == "node-1"
def test_dify_hitl_callback_returns_completed_for_submitted_form() -> None:
repository = MagicMock(spec=HumanInputFormRepository)
repository.get_form.return_value = SimpleNamespace(
id="form-1",
rendered_content="<p>Please approve</p>",
selected_action_id="approve",
submitted_data={"answer": "yes"},
submitted=True,
status=HumanInputFormStatus.SUBMITTED,
expiration_time=naive_utc_now() + timedelta(hours=1),
)
callback = DifyHITLCallback(
form_repository=repository,
node_data=HumanInputNodeData(
title="Approval",
form_content="Please approve",
inputs=[ParagraphInputConfig(output_variable_name="answer")],
user_actions=[UserActionConfig(id="approve", title="Approve")],
),
)
decision = callback(_ctx("run-1", "node-1"))
assert decision.selected_handle == "approve"
assert decision.inputs == {"answer": build_segment("yes")}
assert decision.outputs == {
"answer": build_segment("yes"),
"__action_id": build_segment("approve"),
"__action_value": build_segment("Approve"),
"__rendered_content": build_segment("<p>Please approve</p>"),
}
def test_dify_hitl_callback_returns_timeout_for_explicit_timeout_form() -> None:
repository = MagicMock(spec=HumanInputFormRepository)
repository.get_form.return_value = SimpleNamespace(
id="form-1",
rendered_content="<p>Please approve</p>",
selected_action_id=None,
submitted_data=None,
submitted=False,
status=HumanInputFormStatus.TIMEOUT,
created_at=naive_utc_now(),
expiration_time=naive_utc_now() + timedelta(hours=1),
)
callback = DifyHITLCallback(
form_repository=repository,
node_data=HumanInputNodeData(title="Approval", form_content="Please approve"),
)
decision = callback(_ctx("run-1", "node-1"))
assert decision.selected_handle == "__timeout__"
assert decision.outputs == {
"__action_id": build_segment(""),
"__action_value": build_segment(""),
"__rendered_content": build_segment("<p>Please approve</p>"),
}
def test_dify_hitl_callback_returns_timeout_for_waiting_form_past_node_deadline() -> None:
repository = MagicMock(spec=HumanInputFormRepository)
repository.get_form.return_value = SimpleNamespace(
id="form-1",
rendered_content="<p>Please approve</p>",
selected_action_id=None,
submitted_data=None,
submitted=False,
status=HumanInputFormStatus.WAITING,
created_at=naive_utc_now(),
expiration_time=naive_utc_now() - timedelta(minutes=1),
)
callback = DifyHITLCallback(
form_repository=repository,
node_data=HumanInputNodeData(title="Approval", form_content="Please approve"),
)
decision = callback(_ctx("run-1", "node-1"))
assert decision.selected_handle == "__timeout__"
assert decision.outputs == {
"__action_id": build_segment(""),
"__action_value": build_segment(""),
"__rendered_content": build_segment("<p>Please approve</p>"),
}
def test_dify_hitl_callback_rejects_expired_form_as_invalid_resume_state() -> None:
repository = MagicMock(spec=HumanInputFormRepository)
repository.get_form.return_value = SimpleNamespace(
id="form-1",
rendered_content="<p>Please approve</p>",
selected_action_id=None,
submitted_data=None,
submitted=False,
status=HumanInputFormStatus.EXPIRED,
created_at=naive_utc_now() - timedelta(days=8),
expiration_time=naive_utc_now() + timedelta(hours=1),
)
callback = DifyHITLCallback(
form_repository=repository,
node_data=HumanInputNodeData(title="Approval", form_content="Please approve"),
)
with pytest.raises(AssertionError, match="globally expired human input form"):
callback(_ctx("run-1", "node-1"))
def test_dify_hitl_callback_rejects_waiting_form_past_global_deadline_as_invalid_resume_state() -> None:
repository = MagicMock(spec=HumanInputFormRepository)
repository.get_form.return_value = SimpleNamespace(
id="form-1",
rendered_content="<p>Please approve</p>",
selected_action_id=None,
submitted_data=None,
submitted=False,
status=HumanInputFormStatus.WAITING,
created_at=naive_utc_now() - timedelta(days=8),
expiration_time=naive_utc_now() + timedelta(hours=1),
)
callback = DifyHITLCallback(
form_repository=repository,
node_data=HumanInputNodeData(title="Approval", form_content="Please approve"),
)
with pytest.raises(AssertionError, match="global timeout"):
callback(_ctx("run-1", "node-1"))
@@ -6,8 +6,8 @@ from core.workflow.human_input_policy import (
is_recipient_type_allowed_for_surface,
resolve_variable_select_input_options,
)
from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource
from graphon.nodes.human_input.enums import ValueSourceType
from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource
from core.workflow.nodes.human_input.enums import ValueSourceType
from graphon.runtime import VariablePool
from models.human_input import RecipientType
@@ -602,9 +602,7 @@ class TestDifyNodeFactoryCreateNode:
)
if constructor_name == "HumanInputNode":
form_repository = sentinel.form_repository
factory._human_input_runtime = MagicMock()
factory._human_input_runtime.build_form_repository.return_value = form_repository
factory._build_human_input_callback = MagicMock(return_value=sentinel.hitl_callback)
node_config = {"id": "node-id", "data": {"type": node_type}}
result = factory.create_node(node_config)
@@ -630,11 +628,8 @@ class TestDifyNodeFactoryCreateNode:
assert kwargs["file_reference_factory"] is sentinel.file_reference_factory
factory._bound_tool_file_manager_factory.assert_not_called()
elif constructor_name == "HumanInputNode":
assert kwargs["form_repository"] is form_repository
assert kwargs["file_reference_factory"] is sentinel.file_reference_factory
assert kwargs["runtime"] is factory._human_input_runtime
assert kwargs["file_reference_factory"] is sentinel.file_reference_factory
factory._human_input_runtime.build_form_repository.assert_called_once_with()
assert kwargs["hitl_callback"] is sentinel.hitl_callback
factory._build_human_input_callback.assert_called_once()
elif constructor_name == "ToolNode":
assert kwargs["tool_file_manager"] is sentinel.tool_file_manager
assert kwargs["runtime"] is sentinel.tool_runtime
@@ -643,16 +638,14 @@ class TestDifyNodeFactoryCreateNode:
assert kwargs["unstructured_api_config"] is sentinel.unstructured_api_config
assert kwargs["http_client"] is sentinel.remote_file_http_client
def test_human_input_node_receives_runtime_repository_and_file_reference_factory(
def test_human_input_node_receives_built_hitl_callback(
self,
monkeypatch: pytest.MonkeyPatch,
factory,
) -> None:
created_node = object()
constructor = _node_constructor(return_value=created_node)
form_repository = sentinel.form_repository
factory._human_input_runtime = MagicMock()
factory._human_input_runtime.build_form_repository.return_value = form_repository
factory._build_human_input_callback = MagicMock(return_value=sentinel.hitl_callback)
monkeypatch.setattr(
factory,
"_resolve_node_class",
@@ -663,10 +656,8 @@ class TestDifyNodeFactoryCreateNode:
assert result is created_node
kwargs = constructor.call_args.kwargs
assert kwargs["runtime"] is factory._human_input_runtime
assert kwargs["form_repository"] is form_repository
assert kwargs["file_reference_factory"] is sentinel.file_reference_factory
factory._human_input_runtime.build_form_repository.assert_called_once_with()
assert kwargs["hitl_callback"] is sentinel.hitl_callback
factory._build_human_input_callback.assert_called_once()
def test_tool_node_receives_tool_file_manager(self, monkeypatch: pytest.MonkeyPatch, factory) -> None:
created_node = object()
@@ -34,13 +34,13 @@ from core.workflow.node_runtime import (
build_dify_llm_file_saver,
resolve_dify_run_context,
)
from core.workflow.nodes.human_input.entities import FileInputConfig, FileListInputConfig, HumanInputNodeData
from graphon.file import File, FileTransferMethod, FileType
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.llm_entities import LLMPollingResult, LLMPollingStatus
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelFeature, ModelType
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
from graphon.nodes.human_input.entities import FileInputConfig, FileListInputConfig, HumanInputNodeData
from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol
from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType
from graphon.variables.segments import ArrayFileSegment, FileSegment
@@ -4,8 +4,8 @@ from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
from graphon.nodes.human_input.entities import FormInputConfig
from graphon.nodes.human_input.enums import TimeoutUnit
from core.workflow.nodes.human_input.entities import FormInputConfig
from core.workflow.nodes.human_input.enums import TimeoutUnit
from libs.datetime_utils import naive_utc_now
@@ -6,11 +6,11 @@ from datetime import timedelta
import pytest
from graphon.nodes.human_input.entities import (
from core.workflow.nodes.human_input.entities import (
ParagraphInputConfig,
UserActionConfig,
)
from graphon.nodes.human_input.enums import (
from core.workflow.nodes.human_input.enums import (
TimeoutUnit,
)
from libs.datetime_utils import naive_utc_now
@@ -6,11 +6,11 @@ from datetime import datetime, timedelta
import pytest
from graphon.nodes.human_input.entities import (
from core.workflow.nodes.human_input.entities import (
ParagraphInputConfig,
UserActionConfig,
)
from graphon.nodes.human_input.enums import (
from core.workflow.nodes.human_input.enums import (
TimeoutUnit,
)
from libs.datetime_utils import naive_utc_now
@@ -3,10 +3,16 @@ from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
from graphon.nodes.human_input.enums import FormInputType
from core.workflow.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
from core.workflow.nodes.human_input.enums import FormInputType
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.entities.pause_reason import HitlRequired, PauseReasonType
from models.human_input import RecipientType
from repositories.sqlalchemy_api_workflow_run_repository import _build_human_input_required_reason
from models.workflow import WorkflowPauseReason
from repositories.sqlalchemy_api_workflow_run_repository import (
_build_human_input_required_reason,
_PrivateWorkflowPauseEntity,
)
def _build_form_model() -> SimpleNamespace:
@@ -25,6 +31,7 @@ def _build_form_model() -> SimpleNamespace:
id="form-1",
node_id="node-1",
form_definition=definition.model_dump_json(),
rendered_content="rendered",
expiration_time=expiration_time,
)
@@ -45,6 +52,7 @@ def test_build_human_input_required_reason_prefers_standalone_web_app_token() ->
)
assert reason.node_title == "Ask Name"
assert reason.form_content == "rendered"
assert reason.resolved_default_values == {"name": "Alice"}
assert not hasattr(reason, "form_token")
@@ -62,3 +70,65 @@ def test_build_human_input_required_reason_falls_back_to_console_token() -> None
assert reason.node_id == "node-1"
assert reason.actions[0].id == "approve"
assert not hasattr(reason, "form_token")
def test_workflow_pause_reason_from_entity_persists_hitl_type_for_dify_human_input() -> None:
reason_model = WorkflowPauseReason.from_entity(
pause_id="pause-1",
pause_reason=HumanInputRequired(
form_id="form-1",
form_content="content",
inputs=[],
actions=[],
node_id="node-1",
node_title="Ask Name",
),
)
assert reason_model.type_ == PauseReasonType.HITL_REQUIRED
assert reason_model.form_id == "form-1"
assert reason_model.node_id == "node-1"
def test_workflow_pause_reason_to_entity_restores_graphon_hitl_reason() -> None:
reason_model = WorkflowPauseReason(
pause_id="pause-1",
type_=PauseReasonType.HITL_REQUIRED,
form_id="form-1",
node_id="node-1",
)
reason = reason_model.to_entity()
assert isinstance(reason, HitlRequired)
assert reason.TYPE == PauseReasonType.HITL_REQUIRED
assert reason.session_id == "form-1"
assert reason.node_id == "node-1"
def test_private_workflow_pause_entity_preserves_list_shaped_pause_reasons() -> None:
pause_reasons = [
HumanInputRequired(
form_id="form-1",
form_content="content",
inputs=[],
actions=[],
node_id="node-1",
node_title="Ask Name",
)
]
entity = _PrivateWorkflowPauseEntity(
pause_model=SimpleNamespace(
id="pause-1",
workflow_run_id="run-1",
resumed_at=None,
created_at=datetime(2024, 1, 1, tzinfo=UTC),
),
reason_models=[],
pause_reasons=pause_reasons,
)
result = entity.get_pause_reasons()
assert isinstance(result, list)
assert result == pause_reasons
@@ -6,8 +6,12 @@ from typing import cast
from sqlalchemy.orm import Session, sessionmaker
from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormStatus
from core.workflow.nodes.human_input.entities import (
FormDefinition,
ParagraphInputConfig,
UserActionConfig,
)
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
from libs.datetime_utils import naive_utc_now
from models.execution_extra_content import HumanInputContent as HumanInputContentModel
from models.human_input import HumanInputForm
@@ -17,11 +21,15 @@ from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchem
def test_map_human_input_content_populates_submission_data_from_stored_form_submission() -> None:
expiration_time = naive_utc_now() + timedelta(days=1)
stored_submission_data = {"decision": "approve", "comment": "Looks good"}
rendered_content_template = "Decision: {{#$output.decision#}}, Comment: {{#$output.comment#}}"
form_definition = FormDefinition(
form_content="content",
inputs=[],
form_content=rendered_content_template,
inputs=[
ParagraphInputConfig(output_variable_name="decision"),
ParagraphInputConfig(output_variable_name="comment"),
],
user_actions=[UserActionConfig(id="approve", title="Approve")],
rendered_content="Rendered Approve",
rendered_content=rendered_content_template,
expiration_time=expiration_time,
node_title="Approval",
display_in_ui=True,
@@ -32,7 +40,7 @@ def test_map_human_input_content_populates_submission_data_from_stored_form_subm
workflow_run_id="workflow-run-1",
node_id="node-1",
form_definition=form_definition.model_dump_json(),
rendered_content="Rendered Approve",
rendered_content=rendered_content_template,
expiration_time=expiration_time,
selected_action_id="approve",
submitted_data=json.dumps(stored_submission_data),
@@ -54,6 +62,7 @@ def test_map_human_input_content_populates_submission_data_from_stored_form_subm
assert content is not None
assert content.form_submission_data is not None
assert content.form_submission_data.submitted_data == stored_submission_data
assert content.form_submission_data.rendered_content == "Decision: approve, Comment: Looks good"
def test_map_human_input_content_keeps_waiting_form_without_selected_action() -> None:
@@ -10,8 +10,8 @@ from sqlalchemy.orm import sessionmaker
import models.account as account_module
import services.human_input_file_upload_service as service_module
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from libs.datetime_utils import naive_utc_now
from models.account import Account, Tenant, TenantAccountJoin
from models.base import Base
@@ -14,8 +14,7 @@ from core.repositories.human_input_repository import (
HumanInputFormRecord,
HumanInputFormSubmissionRepository,
)
from graphon.file import File, FileTransferMethod, FileType
from graphon.nodes.human_input.entities import (
from core.workflow.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
FormDefinition,
@@ -24,7 +23,8 @@ from graphon.nodes.human_input.entities import (
StringListSource,
UserActionConfig,
)
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
from graphon.file import File, FileTransferMethod, FileType
from graphon.runtime import GraphRuntimeState, VariablePool
from libs.datetime_utils import naive_utc_now
from models.human_input import RecipientType
@@ -2833,6 +2833,8 @@ class TestWorkflowServiceHumanInputOperations:
workflow = MagicMock()
workflow.environment_variables = []
workflow.graph_dict = {}
node_data = MagicMock()
node_data.extract_variable_selector_to_variable_mapping.return_value = {}
with (
patch("services.workflow_service.db"),
@@ -2840,12 +2842,16 @@ class TestWorkflowServiceHumanInputOperations:
patch("services.workflow_service.WorkflowDraftVariableService"),
patch("services.workflow_service.VariablePool") as mock_pool_cls,
patch("services.workflow_service.DraftVarLoader"),
patch("services.workflow_service.HumanInputNode.extract_variable_selector_to_variable_mapping"),
patch("services.workflow_service.HumanInputNodeData.model_validate", return_value=node_data),
patch("services.workflow_service.load_into_variable_pool"),
patch("services.workflow_service.WorkflowEntry.mapping_user_inputs_to_variable_pool"),
):
service._build_human_input_variable_pool(
app_model=MagicMock(), workflow=workflow, node_config={}, manual_inputs={}, user_id="user-1"
app_model=MagicMock(),
workflow=workflow,
node_config={"id": "node-1", "data": {}},
manual_inputs={},
user_id="user-1",
)
mock_pool_cls.assert_called_once()
@@ -2896,9 +2902,7 @@ class TestWorkflowServiceFreeNodeExecution:
service.validate_features_structure(app, {})
def test_validate_human_input_node_data_error(self, service: WorkflowService) -> None:
with patch(
"graphon.nodes.human_input.entities.HumanInputNodeData.model_validate", side_effect=Exception("error")
):
with patch("services.workflow_service.HumanInputNodeData.model_validate", side_effect=Exception("error")):
with pytest.raises(ValueError, match="Invalid HumanInput node data"):
service._validate_human_input_node_data({})
@@ -2910,49 +2914,24 @@ class TestWorkflowServiceFreeNodeExecution:
def test_build_human_input_node_for_debugging(self, service: WorkflowService) -> None:
"""Cover _build_human_input_node_for_debugging."""
workflow = MagicMock()
workflow.id = "wf-1"
workflow.tenant_id = "t-1"
workflow.app_id = "app-1"
account = MagicMock()
account.id = "u-1"
node_config = {"id": "n-1", "data": {"type": BuiltinNodeTypes.HUMAN_INPUT, "title": "Human Input"}}
variable_pool = MagicMock()
node_data = MagicMock()
node_data.title = "Human Input"
with (
patch("services.workflow_service.DifyGraphInitContext") as mock_graph_init_context_cls,
patch("services.workflow_service.GraphRuntimeState"),
patch(
"services.workflow_service.adapt_human_input_node_data_for_graph",
return_value=sentinel.adapted_node_data,
) as mock_adapt_node_data,
patch("services.workflow_service.build_dify_run_context") as mock_build_dify_run_context,
patch("services.workflow_service.DifyFileReferenceFactory") as mock_file_reference_factory_cls,
patch("services.workflow_service.DifyHumanInputNodeRuntime") as mock_runtime_cls,
patch("services.workflow_service.DifyFileReferenceFactory") as mock_file_reference_factory_cls,
patch("services.workflow_service.HumanInputNode") as mock_node_cls,
patch("services.workflow_service.HumanInputNodeData.model_validate", return_value=node_data),
):
mock_node_cls.validate_node_data.return_value = sentinel.node_data
node = service._build_human_input_node_for_debugging(
workflow=workflow, account=account, node_config=node_config, variable_pool=variable_pool
)
assert node == mock_node_cls.return_value
mock_node_cls.assert_called_once()
mock_graph_init_context_cls.assert_called_once_with(
workflow_id="wf-1",
graph_config=workflow.graph_dict,
run_context=mock_build_dify_run_context.return_value,
call_depth=0,
)
mock_runtime_cls.assert_called_once_with(mock_build_dify_run_context.return_value)
mock_file_reference_factory_cls.assert_called_once_with(mock_build_dify_run_context.return_value)
mock_adapt_node_data.assert_called_once_with(node_config["data"])
mock_node_cls.validate_node_data.assert_called_once_with(sentinel.adapted_node_data)
mock_file_reference_factory_cls.assert_called_once_with(mock_build_dify_run_context.return_value)
mock_node_cls.assert_called_once_with(
node_id="n-1",
data=sentinel.node_data,
graph_init_params=mock_graph_init_context_cls.return_value.to_graph_init_params.return_value,
graph_runtime_state=ANY,
file_reference_factory=mock_file_reference_factory_cls.return_value,
runtime=mock_runtime_cls.return_value,
)
assert node.node_id == "n-1"
assert node.title == "Human Input"
assert node.node_data is node_data
assert node.variable_pool is variable_pool
@@ -17,10 +17,10 @@ from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerat
from core.app.entities.task_entities import StreamEvent
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
from graphon.entities.pause_reason import HumanInputRequired
from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource
from core.workflow.nodes.human_input.enums import ValueSourceType
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource
from graphon.nodes.human_input.enums import ValueSourceType
from graphon.runtime import GraphRuntimeState, VariablePool
from models.enums import CreatorUserRole
from models.human_input import RecipientType
@@ -12,8 +12,8 @@ from core.workflow.human_input_adapter import (
ExternalRecipient,
MemberRecipient,
)
from core.workflow.nodes.human_input.entities import HumanInputNodeData
from graphon.enums import BuiltinNodeTypes
from graphon.nodes.human_input.entities import HumanInputNodeData
from services import workflow_service as workflow_service_module
from services.workflow_service import WorkflowService
@@ -6,7 +6,7 @@ from typing import Any
import pytest
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from tasks import human_input_timeout_tasks as task_module