feat(api): introduce select, file and file list form input types to Human Input node (#36322)

Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: GPT 5.4 <codex@openai.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
QuantumGhost
2026-06-04 01:54:28 +00:00
committed by GitHub
co-authored by JzoNg GPT 5.4 autofix-ci[bot] -LAN-
parent 44725dde74
commit 3c98f96ae8
188 changed files with 11092 additions and 1156 deletions
@@ -0,0 +1,244 @@
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
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 libs.datetime_utils import ensure_naive_utc, naive_utc_now
from models.account import Account, Tenant
from models.enums import CreatorUserRole
from models.human_input import (
HumanInputForm,
HumanInputFormRecipient,
HumanInputFormUploadFile,
HumanInputFormUploadToken,
)
from models.model import App, EndUser
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
from services.human_input_service import FormExpiredError, FormNotFoundError, FormSubmittedError
HITL_UPLOAD_TOKEN_PREFIX = "hitl_upload_"
_TOKEN_RANDOM_BYTES = 32
_TOKEN_GENERATION_ATTEMPTS = 10
@dataclass(frozen=True)
class HumanInputUploadToken:
upload_token: str
expires_at: datetime
@dataclass(frozen=True)
class HumanInputUploadContext:
tenant_id: str
app_id: str
form_id: str
recipient_id: str
upload_token_id: str
owner: Account | EndUser
class InvalidUploadTokenError(Exception):
pass
class HumanInputFileUploadService:
"""Coordinates HITL upload tokens, workflow-run owners, and form-file links.
Standalone HITL uploads must be owned by the original workflow/chatflow
initiator so that resume-time file restoration continues to flow through the
normal file access checks. Delivery-test forms have no workflow run, so their
uploads are scoped to the app creator account inside the form tenant.
"""
_session_maker: sessionmaker[Session]
_workflow_run_repository: APIWorkflowRunRepository
def __init__(
self,
session_factory: sessionmaker[Session] | Engine,
workflow_run_repository: APIWorkflowRunRepository,
) -> None:
if isinstance(session_factory, Engine):
session_factory = sessionmaker(bind=session_factory)
self._session_maker = session_factory
self._workflow_run_repository = workflow_run_repository
def issue_upload_token(self, form_token: str) -> HumanInputUploadToken:
"""Create an upload token for an active human input recipient token."""
with self._session_maker() as session, session.begin():
recipient_model = session.scalar(
select(HumanInputFormRecipient)
.options(selectinload(HumanInputFormRecipient.form))
.where(HumanInputFormRecipient.access_token == form_token)
.limit(1)
)
if recipient_model is None or recipient_model.form is None:
raise FormNotFoundError()
form = recipient_model.form
self._ensure_form_model_active(form)
upload_token = self._generate_unique_upload_token()
token_model = HumanInputFormUploadToken(
tenant_id=form.tenant_id,
app_id=form.app_id,
form_id=form.id,
recipient_id=recipient_model.id,
token=upload_token,
)
session.add(token_model)
# Snapshot the expiry before commit so callers do not depend on the
# session factory's expire_on_commit policy.
token = HumanInputUploadToken(upload_token=upload_token, expires_at=form.expiration_time)
return token
def validate_upload_token(self, upload_token: str) -> HumanInputUploadContext:
"""Resolve an upload token and ensure the bound form is still active."""
query = (
select(HumanInputFormUploadToken)
.options(selectinload(HumanInputFormUploadToken.form))
.where(HumanInputFormUploadToken.token == upload_token)
.limit(1)
)
with self._session_maker(expire_on_commit=False) as session:
token_model = session.scalars(query).first()
if token_model is None:
raise InvalidUploadTokenError()
form_model = token_model.form
if form_model is None:
raise InvalidUploadTokenError()
self._ensure_form_model_active(form_model)
owner = self._resolve_upload_owner(session=session, form_model=form_model)
return HumanInputUploadContext(
tenant_id=token_model.tenant_id,
app_id=token_model.app_id,
form_id=token_model.form_id,
recipient_id=token_model.recipient_id,
upload_token_id=token_model.id,
owner=owner,
)
def record_upload_file(self, *, context: HumanInputUploadContext, file_id: str) -> None:
"""Record that a file was uploaded through a specific form upload token."""
with self._session_maker() as session, session.begin():
session.add(
HumanInputFormUploadFile(
tenant_id=context.tenant_id,
app_id=context.app_id,
form_id=context.form_id,
upload_file_id=file_id,
upload_token_id=context.upload_token_id,
)
)
def _generate_unique_upload_token(self) -> str:
return f"{HITL_UPLOAD_TOKEN_PREFIX}{secrets.token_urlsafe(_TOKEN_RANDOM_BYTES)}"
def _resolve_upload_owner(
self,
*,
session: Session,
form_model: HumanInputForm,
) -> Account | EndUser:
if form_model.workflow_run_id is None:
if form_model.form_kind == HumanInputFormKind.DELIVERY_TEST:
return self._resolve_delivery_test_upload_owner(session=session, form_model=form_model)
raise InvalidUploadTokenError()
workflow_run = self._workflow_run_repository.get_workflow_run_by_id(
tenant_id=form_model.tenant_id,
app_id=form_model.app_id,
run_id=form_model.workflow_run_id,
)
if workflow_run is None:
raise InvalidUploadTokenError()
if workflow_run.created_by_role == CreatorUserRole.END_USER:
end_user = session.scalar(
select(EndUser)
.where(
EndUser.id == workflow_run.created_by,
EndUser.tenant_id == workflow_run.tenant_id,
EndUser.app_id == workflow_run.app_id,
)
.limit(1)
)
if end_user is None:
raise InvalidUploadTokenError()
return end_user
if workflow_run.created_by_role != CreatorUserRole.ACCOUNT:
raise InvalidUploadTokenError()
account = session.scalar(select(Account).where(Account.id == workflow_run.created_by).limit(1))
if account is None:
raise InvalidUploadTokenError()
tenant = session.scalar(select(Tenant).where(Tenant.id == workflow_run.tenant_id).limit(1))
if tenant is None:
raise InvalidUploadTokenError()
# HITL upload runs outside the normal account auth flow, so hydrate the
# account tenant context explicitly before delegating to FileService.
account.current_tenant = tenant
return account
def _resolve_delivery_test_upload_owner(
self,
*,
session: Session,
form_model: HumanInputForm,
) -> Account:
app = session.scalar(
select(App)
.where(
App.id == form_model.app_id,
App.tenant_id == form_model.tenant_id,
)
.limit(1)
)
if app is None or app.created_by is None:
raise InvalidUploadTokenError()
account = session.scalar(select(Account).where(Account.id == app.created_by).limit(1))
if account is None:
raise InvalidUploadTokenError()
tenant = session.scalar(select(Tenant).where(Tenant.id == form_model.tenant_id).limit(1))
if tenant is None:
raise InvalidUploadTokenError()
account.current_tenant = tenant
if account.current_tenant_id != form_model.tenant_id:
raise InvalidUploadTokenError()
return account
@staticmethod
def _ensure_form_model_active(form: HumanInputForm) -> None:
if form.submitted_at is not None or form.status == HumanInputFormStatus.SUBMITTED:
raise FormSubmittedError(form.id)
if form.status in {HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.EXPIRED}:
raise FormExpiredError(form.id)
now = naive_utc_now()
if ensure_naive_utc(form.expiration_time) <= now:
raise FormExpiredError(form.id)
global_timeout_seconds = dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS
if global_timeout_seconds <= 0 or form.workflow_run_id is None:
return
global_deadline = ensure_naive_utc(form.created_at) + timedelta(seconds=global_timeout_seconds)
if global_deadline <= now:
raise FormExpiredError(form.id)
+256 -18
View File
@@ -1,22 +1,37 @@
import logging
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta
from typing import Any
from typing import Any, Protocol, cast
from pydantic import JsonValue, TypeAdapter, ValidationError
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.app.file_access import DatabaseFileAccessController
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
from core.repositories.human_input_repository import (
HumanInputFormRecord,
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 (
FileInputConfig,
FileListInputConfig,
FormDefinition,
FormInputConfig,
HumanInputSubmissionValidationError,
validate_human_input_submission,
SelectInputConfig,
UserActionConfig,
)
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from graphon.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 graphon.runtime import GraphRuntimeState
from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool
from libs.datetime_utils import ensure_naive_utc, naive_utc_now
from libs.exception import BaseHTTPException
from models.human_input import RecipientType
@@ -24,6 +39,13 @@ from models.model import App, AppMode
from repositories.factory import DifyAPIRepositoryFactory
from tasks.app_generate.workflow_execute_task import resume_app_execution
_file_access_controller = DatabaseFileAccessController()
_JsonObjectAdapter: TypeAdapter[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue])
_JsonValueAdapter: TypeAdapter[JsonValue] = TypeAdapter(JsonValue)
_MappingSequenceAdapter: TypeAdapter[Sequence[Mapping[str, Any]]] = TypeAdapter(Sequence[Mapping[str, Any]])
class Form:
def __init__(self, record: HumanInputFormRecord):
@@ -82,7 +104,7 @@ class HumanInputError(Exception):
pass
class FormSubmittedError(HumanInputError, BaseHTTPException):
class FormSubmittedError(BaseHTTPException, HumanInputError):
error_code = "human_input_form_submitted"
description = "This form has already been submitted by another user, form_id={form_id}"
code = 412
@@ -90,37 +112,48 @@ class FormSubmittedError(HumanInputError, BaseHTTPException):
def __init__(self, form_id: str):
template = self.description or "This form has already been submitted by another user, form_id={form_id}"
description = template.format(form_id=form_id)
super().__init__(description=description)
BaseHTTPException.__init__(self, description=description)
class FormNotFoundError(HumanInputError, BaseHTTPException):
class FormNotFoundError(BaseHTTPException, HumanInputError):
error_code = "human_input_form_not_found"
code = 404
class InvalidFormDataError(HumanInputError, BaseHTTPException):
class InvalidFormDataError(BaseHTTPException, HumanInputError):
error_code = "invalid_form_data"
code = 400
def __init__(self, description: str):
super().__init__(description=description)
BaseHTTPException.__init__(self, description=description)
class WebAppDeliveryNotEnabledError(HumanInputError, BaseException):
pass
class FormExpiredError(HumanInputError, BaseHTTPException):
class FormExpiredError(BaseHTTPException, HumanInputError):
error_code = "human_input_form_expired"
code = 412
def __init__(self, form_id: str):
super().__init__(description=f"This form has expired, form_id={form_id}")
BaseHTTPException.__init__(
self,
description=f"This form has expired, form_id={form_id}",
)
logger = logging.getLogger(__name__)
class FormDefinitionProtocol(Protocol):
@property
def inputs(self) -> Sequence[FormInputConfig]: ...
@property
def user_actions(self) -> Sequence[UserActionConfig]: ...
class HumanInputService:
def __init__(
self,
@@ -152,12 +185,19 @@ class HumanInputService:
self._ensure_not_submitted(form)
return form
def resolve_form_inputs(self, form: Form) -> Sequence[FormInputConfig]:
variable_pool = self._load_variable_pool_for_form(form)
return resolve_variable_select_input_options(
form.get_definition().inputs,
variable_pool=variable_pool,
)
def submit_form_by_token(
self,
recipient_type: RecipientType,
form_token: str,
selected_action_id: str,
form_data: Mapping[str, Any],
form_data: Mapping[str, JsonValue],
submission_end_user_id: str | None = None,
submission_user_id: str | None = None,
):
@@ -166,13 +206,17 @@ class HumanInputService:
raise WebAppDeliveryNotEnabledError()
self.ensure_form_active(form)
self._validate_submission(form=form, selected_action_id=selected_action_id, form_data=form_data)
normalized_form_data = self._validate_submission(
form=form,
selected_action_id=selected_action_id,
form_data=form_data,
)
result = self._form_repository.mark_submitted(
form_id=form.id,
recipient_id=form.recipient_id,
selected_action_id=selected_action_id,
form_data=form_data,
form_data=normalized_form_data,
submission_user_id=submission_user_id,
submission_end_user_id=submission_end_user_id,
)
@@ -198,12 +242,17 @@ class HumanInputService:
if form.submitted:
raise FormSubmittedError(form.id)
def _validate_submission(self, form: Form, selected_action_id: str, form_data: Mapping[str, Any]) -> None:
def _validate_submission(
self,
form: Form,
selected_action_id: str,
form_data: Mapping[str, Any],
) -> dict[str, JsonValue]:
definition = form.get_definition()
try:
validate_human_input_submission(
inputs=definition.inputs,
user_actions=definition.user_actions,
return self.validate_and_normalize_submission(
tenant_id=form.tenant_id,
form_definition=definition,
selected_action_id=selected_action_id,
form_data=form_data,
)
@@ -237,6 +286,22 @@ class HumanInputService:
logger.warning("App mode %s does not support resume for workflow run %s", app.mode, workflow_run_id)
def _load_variable_pool_for_form(self, form: Form) -> ReadOnlyVariablePool | None:
workflow_run_id = form.workflow_run_id
if workflow_run_id is None:
return None
workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(self._session_factory)
pause_entity = workflow_run_repo.get_workflow_pause(workflow_run_id)
if pause_entity is None or pause_entity.resumed_at is not None:
return None
resumption_context = WorkflowResumptionContext.loads(pause_entity.get_state().decode())
runtime_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
return runtime_state.variable_pool
def _is_globally_expired(self, form: Form, *, now: datetime | None = None) -> bool:
global_timeout_seconds = dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS
if global_timeout_seconds <= 0:
@@ -247,3 +312,176 @@ class HumanInputService:
created_at = ensure_naive_utc(form.created_at)
global_deadline = created_at + timedelta(seconds=global_timeout_seconds)
return global_deadline <= current
@classmethod
def validate_and_normalize_submission(
cls,
*,
tenant_id: str,
form_definition: FormDefinitionProtocol,
selected_action_id: str,
form_data: Mapping[str, Any],
) -> dict[str, JsonValue]:
"""
Normalize Dify-owned runtime payloads before delegating shape validation to graphon.
graphon owns the form schema and validation rules, while Dify owns tenant-aware file
reconstruction and persistence compatibility for submitted payloads.
"""
normalized_form_data = cls.normalize_submission_data(
tenant_id=tenant_id,
form_definition=form_definition,
form_data=form_data,
)
graphon_validate_human_input_submission(
inputs=form_definition.inputs,
user_actions=form_definition.user_actions,
selected_action_id=selected_action_id,
form_data=normalized_form_data,
)
return normalized_form_data
@classmethod
def normalize_submission_data(
cls,
*,
tenant_id: str,
form_definition: FormDefinitionProtocol,
form_data: Mapping[str, Any],
) -> dict[str, JsonValue]:
normalized_form_data: dict[str, JsonValue] = _JsonObjectAdapter.validate_python(form_data)
inputs_by_name = {form_input.output_variable_name: form_input for form_input in form_definition.inputs}
for name, form_input in inputs_by_name.items():
if name not in form_data:
continue
normalized_form_data[name] = cls._normalize_input_value(
tenant_id=tenant_id,
form_input=form_input,
value=form_data[name],
)
return normalized_form_data
@classmethod
def _normalize_input_value(
cls,
*,
tenant_id: str,
form_input: FormInputConfig,
value: Any,
) -> JsonValue:
if isinstance(form_input, SelectInputConfig):
return cls._normalize_select_value(form_input=form_input, value=value)
if isinstance(form_input, FileInputConfig):
return cls._normalize_file_value(
tenant_id=tenant_id,
form_input=form_input,
value=value,
)
if isinstance(form_input, FileListInputConfig):
return cls._normalize_file_list_value(
tenant_id=tenant_id,
form_input=form_input,
value=value,
)
return _JsonValueAdapter.validate_python(value)
@classmethod
def _normalize_select_value(
cls,
*,
form_input: SelectInputConfig,
value: Any,
) -> JsonValue:
if not isinstance(value, str):
raise HumanInputSubmissionValidationError(
f"Invalid value for select input '{form_input.output_variable_name}': expected string"
)
option_source = form_input.option_source
if option_source.type == ValueSourceType.CONSTANT and value not in option_source.value:
raise HumanInputSubmissionValidationError(
f"Invalid value for select input '{form_input.output_variable_name}': {value}"
)
return value
@classmethod
def _normalize_file_value(
cls,
*,
tenant_id: str,
form_input: FileInputConfig,
value: Any,
) -> JsonValue:
if not isinstance(value, Mapping):
raise HumanInputSubmissionValidationError(
f"Invalid value for file input '{form_input.output_variable_name}': expected mapping"
)
upload_config = cls._build_file_upload_config(form_input=form_input, number_limits=1)
try:
# `build_from_mapping` enforces tenant ownership for persisted upload references.
file = build_from_mapping(
mapping=value,
tenant_id=tenant_id,
config=upload_config,
strict_type_validation=True,
access_controller=_file_access_controller,
)
except ValueError as exc:
raise HumanInputSubmissionValidationError(
f"Invalid value for file input '{form_input.output_variable_name}': {exc}"
) from exc
return cast(JsonValue, file.to_dict())
@classmethod
def _normalize_file_list_value(
cls,
*,
tenant_id: str,
form_input: FileListInputConfig,
value: Any,
) -> JsonValue:
try:
validated_value = _MappingSequenceAdapter.validate_python(value)
except ValidationError as exc:
raise HumanInputSubmissionValidationError(
f"Invalid value for file list input '{form_input.output_variable_name}': {exc}"
) from exc
if not isinstance(value, list):
raise HumanInputSubmissionValidationError(
f"Invalid value for file list input '{form_input.output_variable_name}': expected list"
)
if any(not isinstance(item, Mapping) for item in value):
raise HumanInputSubmissionValidationError(
f"Invalid value for file list input '{form_input.output_variable_name}': expected list of mappings"
)
upload_config = cls._build_file_upload_config(
form_input=form_input,
number_limits=form_input.number_limits,
)
try:
# `build_from_mappings` performs the same tenant-aware ownership validation in batch.
files = build_from_mappings(
mappings=validated_value,
tenant_id=tenant_id,
config=upload_config,
strict_type_validation=True,
access_controller=_file_access_controller,
)
except ValueError as exc:
raise HumanInputSubmissionValidationError(
f"Invalid value for file list input '{form_input.output_variable_name}': {exc}"
) from exc
return cast(JsonValue, [file.to_dict() for file in files])
@staticmethod
def _build_file_upload_config(
*,
form_input: FileInputConfig | FileListInputConfig,
number_limits: int,
) -> FileUploadConfig:
return FileUploadConfig(
allowed_file_types=list(form_input.allowed_file_types),
allowed_file_extensions=list(form_input.allowed_file_extensions),
allowed_file_upload_methods=list(form_input.allowed_file_upload_methods),
number_limits=number_limits,
)
+43 -38
View File
@@ -24,11 +24,17 @@ from core.app.entities.task_entities import (
)
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
from core.workflow.human_input_forms import load_form_tokens_by_form_id
from core.workflow.human_input_policy import HumanInputSurface, enrich_human_input_pause_reasons
from core.workflow.human_input_policy import (
HumanInputSurface,
enrich_human_input_pause_reasons,
resolve_human_input_pause_reason_inputs,
resolve_variable_select_input_options,
)
from graphon.entities import WorkflowStartReason
from graphon.entities.pause_reason import PauseReasonType
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
from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter
from models.human_input import HumanInputForm
from models.model import AppMode, Message
@@ -220,6 +226,7 @@ def _build_snapshot_events(
human_input_surface: HumanInputSurface | None = None,
) -> list[Mapping[str, Any]]:
events: list[Mapping[str, Any]] = []
variable_pool = _load_variable_pool_from_resumption_context(resumption_context)
workflow_started = _build_workflow_started_event(
workflow_run=workflow_run,
@@ -258,6 +265,7 @@ def _build_snapshot_events(
pause_entity=pause_entity,
session_maker=session_maker,
human_input_surface=human_input_surface,
variable_pool=variable_pool,
):
_apply_message_context(human_input_event, message_context)
events.append(human_input_event)
@@ -344,15 +352,10 @@ def _build_human_input_required_events(
pause_entity: WorkflowPauseEntity,
session_maker: sessionmaker[Session] | None,
human_input_surface: HumanInputSurface | None,
variable_pool: ReadOnlyVariablePool | None,
) -> list[dict[str, Any]]:
reasons = [reason.model_dump(mode="json") for reason in pause_entity.get_pause_reasons()]
human_input_form_ids = [
form_id
for reason in reasons
if reason.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED
for form_id in [reason.get("form_id")]
if isinstance(form_id, str)
]
reasons = pause_entity.get_pause_reasons()
human_input_form_ids = [reason.form_id for reason in reasons if isinstance(reason, HumanInputRequired)]
expiration_times_by_form_id: dict[str, int] = {}
display_in_ui_by_form_id: dict[str, bool] = {}
@@ -377,47 +380,33 @@ def _build_human_input_required_events(
events: list[dict[str, Any]] = []
for reason in reasons:
if reason.get("TYPE") != PauseReasonType.HUMAN_INPUT_REQUIRED:
if not isinstance(reason, HumanInputRequired):
continue
form_id_raw = reason.get("form_id")
node_id_raw = reason.get("node_id")
node_title_raw = reason.get("node_title")
form_content_raw = reason.get("form_content")
if not isinstance(form_id_raw, str):
continue
if not isinstance(node_id_raw, str):
continue
if not isinstance(node_title_raw, str):
continue
if not isinstance(form_content_raw, str):
continue
form_id = form_id_raw
node_id = node_id_raw
node_title = node_title_raw
form_content = form_content_raw
inputs = reason.get("inputs")
actions = reason.get("actions")
resolved_default_values = reason.get("resolved_default_values")
form_id = reason.form_id
expiration_time = expiration_times_by_form_id.get(form_id)
if expiration_time is None:
continue
resolved_inputs = resolve_variable_select_input_options(
reason.inputs,
variable_pool=variable_pool,
)
response = HumanInputRequiredResponse(
task_id=task_id,
workflow_run_id=workflow_run_id,
data=HumanInputRequiredResponse.Data(
form_id=form_id,
node_id=node_id,
node_title=node_title,
form_content=form_content,
inputs=inputs if isinstance(inputs, list) else [],
actions=actions if isinstance(actions, list) else [],
node_id=reason.node_id,
node_title=reason.node_title,
form_content=reason.form_content,
inputs=resolved_inputs,
actions=reason.actions,
display_in_ui=display_in_ui_by_form_id.get(form_id, False),
form_token=form_tokens_by_form_id.get(form_id),
resolved_default_values=(resolved_default_values if isinstance(resolved_default_values, dict) else {}),
resolved_default_values=reason.resolved_default_values,
expiration_time=expiration_time,
),
)
@@ -428,6 +417,16 @@ def _build_human_input_required_events(
return events
def _load_variable_pool_from_resumption_context(
resumption_context: WorkflowResumptionContext | None,
) -> ReadOnlyVariablePool | None:
if resumption_context is None:
return None
state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
return state.variable_pool
def _build_node_finished_event(
*,
workflow_run_id: str,
@@ -475,12 +474,18 @@ def _build_pause_event(
) -> dict[str, Any] | None:
paused_nodes: list[str] = []
outputs: dict[str, Any] = {}
variable_pool: ReadOnlyVariablePool | None = None
if resumption_context is not None:
state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
paused_nodes = state.get_paused_nodes()
outputs = dict(WorkflowRuntimeTypeConverter().to_json_encodable(state.outputs or {}))
variable_pool = state.variable_pool
reasons = [reason.model_dump(mode="json") for reason in pause_entity.get_pause_reasons()]
resolved_pause_reasons = resolve_human_input_pause_reason_inputs(
pause_entity.get_pause_reasons(),
variable_pool=variable_pool,
)
reasons = [reason.model_dump(mode="json") for reason in resolved_pause_reasons]
human_input_form_ids = [
form_id
for reason in reasons
+16 -11
View File
@@ -59,7 +59,7 @@ 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, validate_human_input_submission
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
@@ -82,6 +82,7 @@ from services.errors.app import (
WorkflowHashNotEqualError,
WorkflowNotFoundError,
)
from services.human_input_service import HumanInputService
from services.workflow.workflow_converter import WorkflowConverter
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
@@ -1020,7 +1021,7 @@ class WorkflowService:
manual_inputs=inputs or {},
user_id=account.id,
)
node = self._build_human_input_node(
node = self._build_human_input_node_for_debugging(
workflow=draft_workflow,
account=account,
node_config=node_config,
@@ -1080,7 +1081,7 @@ class WorkflowService:
manual_inputs=inputs or {},
user_id=account.id,
)
node = self._build_human_input_node(
node = self._build_human_input_node_for_debugging(
workflow=draft_workflow,
account=account,
node_config=node_config,
@@ -1088,9 +1089,10 @@ class WorkflowService:
)
node_data = node.node_data
validate_human_input_submission(
inputs=node_data.inputs,
user_actions=node_data.user_actions,
human_input_service = HumanInputService(session_factory=sessionmaker(db.engine))
normalized_form_inputs = human_input_service.validate_and_normalize_submission(
tenant_id=app_model.tenant_id,
form_definition=node_data,
selected_action_id=action,
form_data=form_inputs,
)
@@ -1100,11 +1102,14 @@ class WorkflowService:
(user_action for user_action in node_data.user_actions if user_action.id == action),
None,
)
outputs: dict[str, Any] = dict(form_inputs)
outputs: dict[str, Any] = dict(normalized_form_inputs)
outputs["__action_id"] = action
outputs["__action_value"] = selected_action.title if selected_action else ""
outputs["__rendered_content"] = node.render_form_content_with_outputs(
rendered_content, outputs, node_data.outputs_field_names()
rendered_content,
outputs,
node_data.outputs_field_names(),
node_data.inputs,
)
enclosing_node_type_and_id = draft_workflow.get_enclosing_node_type_and_id(node_config)
@@ -1164,7 +1169,7 @@ class WorkflowService:
manual_inputs=inputs or {},
user_id=account.id,
)
node = self._build_human_input_node(
node = self._build_human_input_node_for_debugging(
workflow=draft_workflow,
account=account,
node_config=node_config,
@@ -1257,7 +1262,7 @@ class WorkflowService:
recipients_data.append(DeliveryTestEmailRecipient(email=email, form_token=recipient.access_token))
return recipients_data
def _build_human_input_node(
def _build_human_input_node_for_debugging(
self,
*,
workflow: Workflow,
@@ -1289,8 +1294,8 @@ class WorkflowService:
data=node_data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context),
runtime=DifyHumanInputNodeRuntime(run_context),
file_reference_factory=DifyFileReferenceFactory(run_context),
)
return node