diff --git a/api/Dockerfile b/api/Dockerfile index 84255789533812..b8705c8885a8b2 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -17,7 +17,7 @@ FROM base AS packages RUN apt-get update \ && apt-get install -y --no-install-recommends \ # basic environment - g++ \ + git g++ \ # for building gmpy2 libmpfr-dev libmpc-dev diff --git a/api/controllers/common/human_input.py b/api/controllers/common/human_input.py index 98fe2ce67bcb7f..d9b8f8f9a372c5 100644 --- a/api/controllers/common/human_input.py +++ b/api/controllers/common/human_input.py @@ -1,10 +1,40 @@ import json -from pydantic import BaseModel, JsonValue +from pydantic import BaseModel, Field, JsonValue + +HUMAN_INPUT_FORM_INPUT_EXAMPLE = { + "decision": "approve", + "attachment": { + "transfer_method": "local_file", + "upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e", + "type": "document", + }, + "attachments": [ + { + "transfer_method": "local_file", + "upload_file_id": "1a77f0df-c0e6-461c-987c-e72526f341ee", + "type": "document", + }, + { + "transfer_method": "remote_url", + "url": "https://example.com/report.pdf", + "type": "document", + }, + ], +} class HumanInputFormSubmitPayload(BaseModel): - inputs: dict[str, JsonValue] + inputs: dict[str, JsonValue] = Field( + description=( + "Submitted human input values keyed by output variable name. " + "Use a string for paragraph or select input values, a file mapping for file inputs, " + "and a list of file mappings for file-list inputs. Local file mappings use " + "`transfer_method=local_file` with `upload_file_id`; remote file mappings use " + "`transfer_method=remote_url` with `url` or `remote_url`." + ), + examples=[HUMAN_INPUT_FORM_INPUT_EXAMPLE], + ) action: str diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py index 2b38a84b0ecafc..87cdca49883ef6 100644 --- a/api/controllers/service_api/app/human_input_form.py +++ b/api/controllers/service_api/app/human_input_form.py @@ -7,6 +7,7 @@ import json import logging +from collections.abc import Sequence from flask import Response from flask_restx import Resource @@ -18,6 +19,7 @@ from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface from extensions.ext_database import db +from graphon.nodes.human_input.entities import FormInputConfig from libs.helper import to_timestamp from models.model import App, EndUser from services.human_input_service import Form, FormNotFoundError, HumanInputService @@ -28,11 +30,11 @@ register_schema_models(service_api_ns, HumanInputFormSubmitPayload) -def _jsonify_form_definition(form: Form) -> Response: - definition_payload = form.get_definition().model_dump() +def _jsonify_form_definition(form: Form, *, inputs: Sequence[FormInputConfig] = ()) -> Response: + definition_payload = form.get_definition().model_dump(mode="json") payload = { "form_content": definition_payload["rendered_content"], - "inputs": definition_payload["inputs"], + "inputs": [form_input.model_dump(mode="json") for form_input in inputs], "resolved_default_values": stringify_form_default_values(definition_payload["default_values"]), "user_actions": definition_payload["user_actions"], "expiration_time": to_timestamp(form.expiration_time), @@ -75,7 +77,8 @@ def get(self, app_model: App, form_token: str): _ensure_form_belongs_to_app(form, app_model) _ensure_form_is_allowed_for_service_api(form) service.ensure_form_active(form) - return _jsonify_form_definition(form) + inputs = service.resolve_form_inputs(form) + return _jsonify_form_definition(form, inputs=inputs) @service_api_ns.expect(service_api_ns.models[HumanInputFormSubmitPayload.__name__]) @service_api_ns.doc("submit_human_input_form") diff --git a/api/controllers/web/__init__.py b/api/controllers/web/__init__.py index cfa39e0dfd329b..d4b0829dea3027 100644 --- a/api/controllers/web/__init__.py +++ b/api/controllers/web/__init__.py @@ -23,6 +23,7 @@ feature, files, forgot_password, + human_input_file_upload, human_input_form, login, message, @@ -46,6 +47,7 @@ "feature", "files", "forgot_password", + "human_input_file_upload", "human_input_form", "login", "message", diff --git a/api/controllers/web/human_input_file_upload.py b/api/controllers/web/human_input_file_upload.py new file mode 100644 index 00000000000000..56665781e7a89c --- /dev/null +++ b/api/controllers/web/human_input_file_upload.py @@ -0,0 +1,181 @@ +import httpx +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, Field, HttpUrl + +import services +from controllers.common import helpers +from controllers.common.errors import ( + BlockedFileExtensionError, + FileTooLargeError, + NoFileUploadedError, + RemoteFileUploadError, + TooManyFilesError, + UnsupportedFileTypeError, +) +from controllers.common.schema import register_schema_models +from controllers.web import web_ns +from core.helper import ssrf_proxy +from extensions.ext_database import db +from fields.file_fields import FileResponse, FileWithSignedUrl +from graphon.file import helpers as file_helpers +from libs.exception import BaseHTTPException +from services.file_service import FileService +from services.human_input_file_upload_service import ( + HITL_UPLOAD_TOKEN_PREFIX, + HumanInputFileUploadService, + InvalidUploadTokenError, +) + + +class InvalidUploadTokenBadRequestError(BaseHTTPException): + error_code = "invalid_upload_token" + description = "Invalid upload token." + code = 400 + + +class InvalidUploadTokenUnauthorizedError(BaseHTTPException): + error_code = "invalid_upload_token" + description = "Upload token is required." + code = 401 + + +class InvalidUploadTokenForbiddenError(BaseHTTPException): + error_code = "invalid_upload_token" + description = "Upload token is invalid or expired." + code = 403 + + +class HumanInputRemoteFileUploadPayload(BaseModel): + url: HttpUrl = Field(description="Remote file URL") + + +register_schema_models(web_ns, HumanInputRemoteFileUploadPayload, FileResponse, FileWithSignedUrl) + + +def _extract_hitl_upload_token() -> str: + """Read HITL upload token from Authorization without invoking other bearer auth chains.""" + + authorization = request.headers.get("Authorization") + if authorization is None: + raise InvalidUploadTokenUnauthorizedError() + + parts = authorization.split() + if len(parts) != 2: + raise InvalidUploadTokenUnauthorizedError() + + scheme, token = parts + if scheme.lower() != "bearer": + raise InvalidUploadTokenBadRequestError() + if not token: + raise InvalidUploadTokenUnauthorizedError() + if not token.startswith(HITL_UPLOAD_TOKEN_PREFIX): + raise InvalidUploadTokenBadRequestError() + return token + + +def _validate_context(service: HumanInputFileUploadService, token: str): + try: + return service.validate_upload_token(token) + except InvalidUploadTokenError as exc: + raise InvalidUploadTokenForbiddenError() from exc + + +def _parse_local_upload_file(): + if "file" not in request.files: + raise NoFileUploadedError() + if len(request.files) > 1: + raise TooManyFilesError() + + file = request.files["file"] + if not file.filename: + from controllers.common.errors import FilenameNotExistsError + + raise FilenameNotExistsError() + + return file + + +@web_ns.route("/form/human_input/files/upload") +class HumanInputFileUploadApi(Resource): + def post(self): + """Upload one local file for a HITL human input form.""" + + token = _extract_hitl_upload_token() + upload_service = HumanInputFileUploadService(db.engine) + context = _validate_context(upload_service, token) + file = _parse_local_upload_file() + + try: + upload_file = FileService(db.engine).upload_file( + filename=file.filename or "", + content=file.read(), + mimetype=file.mimetype, + user=context.owner, + source=None, + ) + except services.errors.file.FileTooLargeError as file_too_large_error: + raise FileTooLargeError(file_too_large_error.description) + except services.errors.file.UnsupportedFileTypeError: + raise UnsupportedFileTypeError() + except services.errors.file.BlockedFileExtensionError as exc: + raise BlockedFileExtensionError() from exc + + upload_service.record_upload_file(context=context, file_id=upload_file.id) + response = FileResponse.model_validate(upload_file, from_attributes=True) + return response.model_dump(mode="json"), 201 + + +@web_ns.route("/form/human_input/files/remote-upload") +class HumanInputRemoteFileUploadApi(Resource): + def post(self): + """Upload one remote URL file for a HITL human input form.""" + + token = _extract_hitl_upload_token() + upload_service = HumanInputFileUploadService(db.engine) + context = _validate_context(upload_service, token) + payload = HumanInputRemoteFileUploadPayload.model_validate(request.get_json(silent=True) or {}) + url = str(payload.url) + + try: + resp = ssrf_proxy.head(url=url) + if resp.status_code != httpx.codes.OK: + resp = ssrf_proxy.get(url=url, timeout=3, follow_redirects=True) + if resp.status_code != httpx.codes.OK: + raise RemoteFileUploadError(f"Failed to fetch file from {url}: {resp.text}") + except httpx.RequestError as exc: + raise RemoteFileUploadError(f"Failed to fetch file from {url}: {str(exc)}") + + file_info = helpers.guess_file_info_from_response(resp) + if not FileService.is_file_size_within_limit(extension=file_info.extension, file_size=file_info.size): + raise FileTooLargeError() + + content = resp.content if resp.request.method == "GET" else ssrf_proxy.get(url).content + + try: + upload_file = FileService(db.engine).upload_file( + filename=file_info.filename, + content=content, + mimetype=file_info.mimetype, + user=context.owner, + source_url=url, + ) + except services.errors.file.FileTooLargeError as file_too_large_error: + raise FileTooLargeError(file_too_large_error.description) + except services.errors.file.UnsupportedFileTypeError: + raise UnsupportedFileTypeError() + except services.errors.file.BlockedFileExtensionError as exc: + raise BlockedFileExtensionError() from exc + + upload_service.record_upload_file(context=context, file_id=upload_file.id) + payload1 = FileWithSignedUrl( + id=upload_file.id, + name=upload_file.name, + size=upload_file.size, + extension=upload_file.extension, + url=file_helpers.get_signed_file_url(upload_file_id=upload_file.id), + mime_type=upload_file.mime_type, + created_by=upload_file.created_by, + created_at=int(upload_file.created_at.timestamp()), + ) + return payload1.model_dump(mode="json"), 201 diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index 69297450c9a319..a04cfe068538d2 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -4,27 +4,40 @@ import json import logging +from collections.abc import Sequence from typing import Any, NotRequired, TypedDict from flask import Response, request from flask_restx import Resource +from pydantic import BaseModel from sqlalchemy import select from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values +from controllers.common.schema import register_schema_models from controllers.web import web_ns from controllers.web.error import NotFoundError, WebFormRateLimitExceededError from controllers.web.site import serialize_app_site_payload from extensions.ext_database import db +from graphon.nodes.human_input.entities import FormInputConfig from libs.helper import RateLimiter, extract_remote_ip, to_timestamp from models.account import TenantStatus from models.model import App, Site +from services.human_input_file_upload_service import HumanInputFileUploadService from services.human_input_service import Form, FormNotFoundError, HumanInputService logger = logging.getLogger(__name__) +class HumanInputUploadTokenResponse(BaseModel): + upload_token: str + expires_at: int + + +register_schema_models(web_ns, HumanInputUploadTokenResponse) + + _FORM_SUBMIT_RATE_LIMITER = RateLimiter( prefix="web_form_submit_rate_limit", max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS, @@ -35,6 +48,11 @@ max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS, time_window=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_WINDOW_SECONDS, ) +_FORM_UPLOAD_TOKEN_RATE_LIMITER = RateLimiter( + prefix="web_form_upload_token_rate_limit", + max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS, + time_window=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_WINDOW_SECONDS, +) class FormDefinitionPayload(TypedDict): @@ -46,12 +64,17 @@ class FormDefinitionPayload(TypedDict): site: NotRequired[dict] -def _jsonify_form_definition(form: Form, site_payload: dict | None = None) -> Response: +def _jsonify_form_definition( + form: Form, + *, + inputs: Sequence[FormInputConfig] = (), + site_payload: dict | None = None, +) -> Response: """Return the form payload (optionally with site) as a JSON response.""" - definition_payload = form.get_definition().model_dump() + definition_payload = form.get_definition().model_dump(mode="json") payload: FormDefinitionPayload = { "form_content": definition_payload["rendered_content"], - "inputs": definition_payload["inputs"], + "inputs": [i.model_dump(mode="json") for i in inputs], "resolved_default_values": stringify_form_default_values(definition_payload["default_values"]), "user_actions": definition_payload["user_actions"], "expiration_time": to_timestamp(form.expiration_time), @@ -61,6 +84,33 @@ def _jsonify_form_definition(form: Form, site_payload: dict | None = None) -> Re return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json") +@web_ns.route("/form/human_input//upload-token") +class HumanInputFormUploadTokenApi(Resource): + """API for issuing HITL upload tokens for active human input forms.""" + + def post(self, form_token: str): + """ + Issue an upload token for a human input form. + + POST /api/form/human_input//upload-token + """ + ip_address = extract_remote_ip(request) + if _FORM_UPLOAD_TOKEN_RATE_LIMITER.is_rate_limited(ip_address): + raise WebFormRateLimitExceededError() + _FORM_UPLOAD_TOKEN_RATE_LIMITER.increment_rate_limit(ip_address) + + try: + token = HumanInputFileUploadService(db.engine).issue_upload_token(form_token) + except FormNotFoundError: + raise NotFoundError("Form not found") + + response = HumanInputUploadTokenResponse( + upload_token=token.upload_token, + expires_at=to_timestamp(token.expires_at), + ) + return response.model_dump(mode="json"), 200 + + @web_ns.route("/form/human_input/") class HumanInputFormApi(Resource): """API for getting and submitting human input forms via the web app.""" @@ -89,8 +139,13 @@ def get(self, form_token: str): service.ensure_form_active(form) app_model, site = _get_app_site_from_form(form) + inputs = service.resolve_form_inputs(form) - return _jsonify_form_definition(form, site_payload=serialize_app_site_payload(app_model, site, None)) + return _jsonify_form_definition( + form, + inputs=inputs, + site_payload=serialize_app_site_payload(app_model, site, None), + ) # def post(self, _app_model: App, _end_user: EndUser, form_token: str): def post(self, form_token: str): diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 4a741d3154b4e7..52051f87edf536 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -52,7 +52,11 @@ from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE from core.trigger.trigger_manager import TriggerManager 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, +) from core.workflow.system_variables import SystemVariableKey, system_variables_to_mapping from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_database import db @@ -318,8 +322,13 @@ def workflow_pause_to_stream_response( encoded_outputs = self._encode_outputs(event.outputs) or {} if self._application_generate_entity.invoke_from == InvokeFrom.SERVICE_API: encoded_outputs = {} - pause_reasons = [reason.model_dump(mode="json") for reason in event.reasons] - human_input_form_ids = [reason.form_id for reason in event.reasons if isinstance(reason, HumanInputRequired)] + variable_pool = graph_runtime_state.variable_pool + resolved_reasons = resolve_human_input_pause_reason_inputs( + event.reasons, + variable_pool=variable_pool, + ) + pause_reasons = [reason.model_dump(mode="json") for reason in resolved_reasons] + human_input_form_ids = [reason.form_id for reason in resolved_reasons if isinstance(reason, HumanInputRequired)] expiration_times_by_form_id: dict[str, datetime] = {} display_in_ui_by_form_id: dict[str, bool] = {} form_token_by_form_id: dict[str, str] = {} @@ -360,7 +369,7 @@ def workflow_pause_to_stream_response( responses: list[StreamResponse] = [] - for reason in event.reasons: + for reason in resolved_reasons: if isinstance(reason, HumanInputRequired): expiration_time = expiration_times_by_form_id.get(reason.form_id) if expiration_time is None: @@ -408,17 +417,19 @@ def human_input_form_filled_to_stream_response( self, *, event: QueueHumanInputFormFilledEvent, task_id: str ) -> HumanInputFormFilledResponse: run_id = self._ensure_workflow_run_id() - return HumanInputFormFilledResponse( - task_id=task_id, - workflow_run_id=run_id, - data=HumanInputFormFilledResponse.Data( - node_id=event.node_id, - node_title=event.node_title, - rendered_content=event.rendered_content, - action_id=event.action_id, - action_text=event.action_text, - ), + data = HumanInputFormFilledResponse.Data( + node_id=event.node_id, + node_title=event.node_title, + rendered_content=event.rendered_content, + action_id=event.action_id, + action_text=event.action_text, ) + if event.submitted_data is not None: + runtime_type_converter = WorkflowRuntimeTypeConverter() + + data.submitted_data = runtime_type_converter.value_to_json_encodable_recursive(event.submitted_data) + + return HumanInputFormFilledResponse(task_id=task_id, workflow_run_id=run_id, data=data) def human_input_form_timeout_to_stream_response( self, *, event: QueueHumanInputFormTimeoutEvent, task_id: str diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 84e9573416de32..c7af606419388a 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -435,6 +435,7 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent): rendered_content=event.rendered_content, action_id=event.action_id, action_text=event.action_text, + submitted_data=event.submitted_data, ) ) case NodeRunHumanInputFormTimeoutEvent(): diff --git a/api/core/app/entities/queue_entities.py b/api/core/app/entities/queue_entities.py index 221b7fb058b2c5..a0e7881edeb6fb 100644 --- a/api/core/app/entities/queue_entities.py +++ b/api/core/app/entities/queue_entities.py @@ -11,6 +11,7 @@ from graphon.entities.pause_reason import PauseReason from graphon.enums import NodeType, WorkflowNodeExecutionMetadataKey from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk +from graphon.variables.segments import Segment class QueueEvent(StrEnum): @@ -508,6 +509,10 @@ class QueueHumanInputFormFilledEvent(AppQueueEvent): action_id: str action_text: str + # Keep the field name aligned with Graphon so the app-layer bridge does not + # need to translate between two equivalent payload names. + submitted_data: Mapping[str, Segment] | None = None + class QueueHumanInputFormTimeoutEvent(AppQueueEvent): """ diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index 3a33899bdf264c..defec9f9461633 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -342,6 +342,8 @@ class Data(BaseModel): action_id: str action_text: str + submitted_data: Mapping[str, Any] | None = None + event: StreamEvent = StreamEvent.HUMAN_INPUT_FORM_FILLED workflow_run_id: str data: Data diff --git a/api/core/entities/execution_extra_content.py b/api/core/entities/execution_extra_content.py index f11c6700696444..43252ccb2cec0a 100644 --- a/api/core/entities/execution_extra_content.py +++ b/api/core/entities/execution_extra_content.py @@ -3,7 +3,7 @@ from collections.abc import Mapping, Sequence from typing import Any, TypeAlias -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, JsonValue from graphon.nodes.human_input.entities import FormInputConfig, UserActionConfig from models.execution_extra_content import ExecutionContentType @@ -19,6 +19,8 @@ class HumanInputFormDefinition(BaseModel): inputs: Sequence[FormInputConfig] = Field(default_factory=list) actions: Sequence[UserActionConfig] = Field(default_factory=list) display_in_ui: bool = False + + # `form_token` is `None` if the corresponding form has been submitted. form_token: str | None = None resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) expiration_time: int @@ -29,16 +31,31 @@ class HumanInputFormSubmissionData(BaseModel): node_id: str node_title: str + + # deprecate: the rendered_content is deprecated and only for historical reasons. rendered_content: str + + # The identifier of action user has chosen. action_id: str + # The button text of the action user has chosen. action_text: str + # submitted_data records the submitted form data. + # Keys correspond to `output_variable_name` of HumanInput inputs. + # Values are serialized JSON forms of runtime values, including file dictionaries. + # + # For form submitted before this field is introduced, this field is populated from + # the stored submission data. + submitted_data: Mapping[str, JsonValue] | None = None + class HumanInputContent(BaseModel): model_config = ConfigDict(frozen=True) workflow_run_id: str submitted: bool + # Both the form_defintion and the form_submission_data are present in + # HumanInputContent. For historical records, the form_definition: HumanInputFormDefinition | None = None form_submission_data: HumanInputFormSubmissionData | None = None type: ExecutionContentType = Field(default=ExecutionContentType.HUMAN_INPUT) diff --git a/api/core/workflow/human_input_policy.py b/api/core/workflow/human_input_policy.py index 798eb8723ff336..07413165e67c4c 100644 --- a/api/core/workflow/human_input_policy.py +++ b/api/core/workflow/human_input_policy.py @@ -4,7 +4,11 @@ from enum import StrEnum from typing import Any -from graphon.entities.pause_reason import PauseReasonType +from graphon.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType +from graphon.nodes.human_input.entities import FormInputConfig, SelectInputConfig +from graphon.nodes.human_input.enums import ValueSourceType +from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool +from graphon.variables import ArrayStringSegment from models.human_input import RecipientType @@ -71,3 +75,69 @@ def enrich_human_input_pause_reasons( updated["expiration_time"] = expiration_time enriched.append(updated) return enriched + + +def resolve_variable_select_input_options( + inputs: Sequence[FormInputConfig], + *, + variable_pool: ReadOnlyVariablePool | None, +) -> list[FormInputConfig]: + """Resolve variable-backed select options to runtime values.""" + + # This function replace the SelectInputConfig.option_source.value + # field with acutial runtime values when option_source.type is VARIABLE. + # + # This is a dirty hacks. However it does reduces the logic leaked to callers of + # the api. + resolved_inputs: list[FormInputConfig] = [] + + if variable_pool is None: + return list(inputs) + + for form_input in inputs: + if not isinstance(form_input, SelectInputConfig): + resolved_inputs.append(form_input) + continue + + option_source = form_input.option_source + if option_source.type != ValueSourceType.VARIABLE: + resolved_inputs.append(form_input) + continue + + option_values = variable_pool.get(option_source.selector) + if option_values is None: + resolved_inputs.append(form_input) + continue + if not isinstance(option_values, ArrayStringSegment): + raise TypeError(f"expected ArrayStringSegment, got {type(option_values)}") + + updated_option_source = option_source.model_copy(update={"value": option_values.value}) + # Ensure frontend receives concrete select options instead of unresolved selectors. + resolved_inputs.append( + form_input.model_copy( + update={"option_source": updated_option_source}, + ) + ) + return resolved_inputs + + +def resolve_human_input_pause_reason_inputs( + reasons: Sequence[PauseReason], + *, + variable_pool: ReadOnlyVariablePool | None, +) -> list[PauseReason]: + if variable_pool is None: + return list(reasons) + + resolved_reasons: list[PauseReason] = [] + for reason in reasons: + if not isinstance(reason, HumanInputRequired): + resolved_reasons.append(reason) + continue + + resolved_inputs = resolve_variable_select_input_options( + reason.inputs, + variable_pool=variable_pool, + ) + resolved_reasons.append(reason.model_copy(update={"inputs": resolved_inputs})) + return resolved_reasons diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index 7ea03a4a33c0d8..be14aa92ec0436 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -35,7 +35,7 @@ from core.workflow.file_reference import build_file_reference from extensions.ext_database import db from factories import file_factory -from graphon.file import FileTransferMethod, FileType +from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities import LLMMode from graphon.model_runtime.entities.llm_entities import ( LLMResult, @@ -47,7 +47,12 @@ from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool from graphon.model_runtime.entities.model_entities import AIModelEntity from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel -from graphon.nodes.human_input.entities import HumanInputNodeData +from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + FormInputConfig, + HumanInputNodeData, +) from graphon.nodes.llm.runtime_protocols import ( LLMProtocol, PromptMessageSerializerProtocol, @@ -83,7 +88,6 @@ if TYPE_CHECKING: from core.tools.__base.tool import Tool from core.tools.entities.tool_entities import ToolInvokeMessage as CoreToolInvokeMessage - from graphon.file import File from graphon.nodes.llm.file_saver import LLMFileSaver from graphon.nodes.tool.entities import ToolNodeData @@ -682,6 +686,7 @@ def __init__( self._run_context = resolve_dify_run_context(run_context) self._workflow_execution_id_getter = workflow_execution_id_getter self._form_repository = form_repository + self._file_reference_factory = DifyFileReferenceFactory(self._run_context) def _invoke_source(self) -> str: invoke_from = self._run_context.invoke_from @@ -735,6 +740,23 @@ def get_form(self, *, node_id: str) -> HumanInputFormStateProtocol | None: repo = self.build_form_repository() return repo.get_form(node_id) + def restore_submitted_data( + self, + *, + node_data: HumanInputNodeData, + submitted_data: Mapping[str, Any], + ) -> Mapping[str, Any]: + restored_data: dict[str, Any] = dict(submitted_data) + for input_config in node_data.inputs: + output_variable_name = input_config.output_variable_name + if output_variable_name not in submitted_data: + continue + restored_data[output_variable_name] = self._restore_submitted_value( + input_config=input_config, + value=submitted_data[output_variable_name], + ) + return restored_data + def create_form( self, *, @@ -755,6 +777,55 @@ def create_form( ) return repo.create_form(params) + def _restore_submitted_value( + self, + *, + input_config: FormInputConfig, + value: Any, + ) -> Any: + if isinstance(input_config, FileInputConfig): + return self._restore_submitted_file_value( + output_variable_name=input_config.output_variable_name, + value=value, + ) + if isinstance(input_config, FileListInputConfig): + return self._restore_submitted_file_list_value( + output_variable_name=input_config.output_variable_name, + value=value, + ) + return value + + def _restore_submitted_file_value( + self, + *, + output_variable_name: str, + value: Any, + ) -> Any: + if not isinstance(value, Mapping): + msg = ( + "HumanInput file submission must be persisted as a mapping, " + f"output_variable_name={output_variable_name}" + ) + raise ValueError(msg) + return self._file_reference_factory.build_from_mapping(mapping=value) + + def _restore_submitted_file_list_value( + self, + *, + output_variable_name: str, + value: Any, + ) -> list[Any]: + if not isinstance(value, list): + msg = ( + "HumanInput file-list submission must be persisted as a list, " + f"output_variable_name={output_variable_name}" + ) + raise ValueError(msg) + if any(not isinstance(item, Mapping) for item in value): + msg = f"HumanInput file-list submission must contain mappings, output_variable_name={output_variable_name}" + raise ValueError(msg) + return [self._file_reference_factory.build_from_mapping(mapping=item) for item in value] + def build_dify_llm_file_saver( *, diff --git a/api/factories/file_factory/builders.py b/api/factories/file_factory/builders.py index 4fb976f0e74e47..7026af23b850c0 100644 --- a/api/factories/file_factory/builders.py +++ b/api/factories/file_factory/builders.py @@ -5,7 +5,7 @@ import mimetypes import uuid from collections.abc import Mapping, Sequence -from typing import Any +from typing import Any, Literal, NotRequired, TypedDict, assert_never, cast from sqlalchemy import select @@ -19,10 +19,58 @@ from .remote import get_remote_file_info from .validation import is_file_valid_with_config +type FileTypeValue = FileType | Literal["image", "document", "audio", "video", "custom"] + +type _LocalFileTransferMethod = Literal["local_file", FileTransferMethod.LOCAL_FILE] +type _RemoteUrlTransferMethod = Literal["remote_url", FileTransferMethod.REMOTE_URL] +type _ToolFileTransferMethod = Literal["tool_file", FileTransferMethod.TOOL_FILE] +type _DatasourceFileTransferMethod = Literal["datasource_file", FileTransferMethod.DATASOURCE_FILE] + + +class LocalFileMapping(TypedDict): + transfer_method: _LocalFileTransferMethod + id: NotRequired[str | None] # Read as the graph-layer File.file_id. + type: NotRequired[FileTypeValue | None] # Read for type override and upload config validation. + upload_file_id: NotRequired[str | None] # File id lookup priority 1. + reference: NotRequired[str | None] # File id lookup priority 2; may be an opaque file reference. + related_id: NotRequired[str | None] # File id lookup priority 3; legacy persisted field. + + +class RemoteUrlMapping(TypedDict): + transfer_method: _RemoteUrlTransferMethod + id: NotRequired[str | None] # Read as the graph-layer File.file_id. + type: NotRequired[FileTypeValue | None] # Read for type override and upload config validation. + upload_file_id: NotRequired[str | None] # Persisted UploadFile lookup priority 1. + reference: NotRequired[str | None] # Persisted UploadFile lookup priority 2; may be an opaque file reference. + related_id: NotRequired[str | None] # Persisted UploadFile lookup priority 3; legacy persisted field. + url: NotRequired[str | None] # External URL lookup priority 1 when no UploadFile id is resolved. + remote_url: NotRequired[str | None] # External URL lookup priority 2 when no UploadFile id is resolved. + + +class ToolFileMapping(TypedDict): + transfer_method: _ToolFileTransferMethod + id: NotRequired[str | None] # Read as the graph-layer File.file_id. + type: NotRequired[FileTypeValue | None] # Read for type override and upload config validation. + tool_file_id: NotRequired[str | None] # ToolFile lookup priority 1. + reference: NotRequired[str | None] # ToolFile lookup priority 2; may be an opaque file reference. + related_id: NotRequired[str | None] # ToolFile lookup priority 3; legacy persisted field. + + +class DatasourceFileMapping(TypedDict): + transfer_method: _DatasourceFileTransferMethod + type: NotRequired[FileTypeValue | None] # Read for type override and upload config validation. + datasource_file_id: NotRequired[str | None] # UploadFile lookup priority 1 for datasource-backed files. + reference: NotRequired[str | None] # UploadFile lookup priority 2; may be an opaque file reference. + related_id: NotRequired[str | None] # UploadFile lookup priority 3; legacy persisted field. + + +type FileMapping = LocalFileMapping | RemoteUrlMapping | ToolFileMapping | DatasourceFileMapping +type FileMappingInput = FileMapping | Mapping[str, Any] + def build_from_mapping( *, - mapping: Mapping[str, Any], + mapping: FileMappingInput, tenant_id: str, config: FileUploadConfig | None = None, strict_type_validation: bool = False, @@ -32,18 +80,45 @@ def build_from_mapping( if not transfer_method_value: raise ValueError("transfer_method is required in file mapping") - transfer_method = FileTransferMethod.value_of(transfer_method_value) - build_func = _get_build_function(transfer_method) - file = build_func( - mapping=mapping, - tenant_id=tenant_id, - transfer_method=transfer_method, - strict_type_validation=strict_type_validation, - access_controller=access_controller, - ) + transfer_method = FileTransferMethod.value_of(str(transfer_method_value)) + match transfer_method: + case FileTransferMethod.LOCAL_FILE: + file = _build_from_local_file( + mapping=cast(LocalFileMapping, mapping), + tenant_id=tenant_id, + transfer_method=transfer_method, + strict_type_validation=strict_type_validation, + access_controller=access_controller, + ) + case FileTransferMethod.REMOTE_URL: + file = _build_from_remote_url( + mapping=cast(RemoteUrlMapping, mapping), + tenant_id=tenant_id, + transfer_method=transfer_method, + strict_type_validation=strict_type_validation, + access_controller=access_controller, + ) + case FileTransferMethod.TOOL_FILE: + file = _build_from_tool_file( + mapping=cast(ToolFileMapping, mapping), + tenant_id=tenant_id, + transfer_method=transfer_method, + strict_type_validation=strict_type_validation, + access_controller=access_controller, + ) + case FileTransferMethod.DATASOURCE_FILE: + file = _build_from_datasource_file( + mapping=cast(DatasourceFileMapping, mapping), + tenant_id=tenant_id, + transfer_method=transfer_method, + strict_type_validation=strict_type_validation, + access_controller=access_controller, + ) + case _: + assert_never(transfer_method) if config and not is_file_valid_with_config( - input_file_type=mapping.get("type", FileType.CUSTOM), + input_file_type=mapping.get("type") or FileType.CUSTOM, file_extension=file.extension or "", file_transfer_method=file.transfer_method, config=config, @@ -87,36 +162,33 @@ def build_from_mappings( return files -def _get_build_function(transfer_method: FileTransferMethod): - build_functions = { - FileTransferMethod.LOCAL_FILE: _build_from_local_file, - FileTransferMethod.REMOTE_URL: _build_from_remote_url, - FileTransferMethod.TOOL_FILE: _build_from_tool_file, - FileTransferMethod.DATASOURCE_FILE: _build_from_datasource_file, - } - build_func = build_functions.get(transfer_method) - if build_func is None: - raise ValueError(f"Invalid file transfer method: {transfer_method}") - return build_func - - def _resolve_file_type( *, detected_file_type: FileType, - specified_type: str | None, + specified_type: FileTypeValue | str | None, strict_type_validation: bool, ) -> FileType: - if strict_type_validation and specified_type and detected_file_type.value != specified_type: + """Resolve the graph file type from detected metadata and submitted form type. + + ``custom`` is a configured extension bucket rather than a MIME-derived type, + so strict validation must leave extension checks to the upload config. + """ + if not specified_type: + return detected_file_type + + specified_file_type = FileType(specified_type) + if specified_file_type == FileType.CUSTOM: + return FileType.CUSTOM + + if strict_type_validation and detected_file_type != specified_file_type: raise ValueError("Detected file type does not match the specified type. Please verify the file.") - if specified_type and specified_type != "custom": - return FileType(specified_type) - return detected_file_type + return specified_file_type def _build_from_local_file( *, - mapping: Mapping[str, Any], + mapping: LocalFileMapping, tenant_id: str, transfer_method: FileTransferMethod, strict_type_validation: bool = False, @@ -143,7 +215,7 @@ def _build_from_local_file( detected_file_type = standardize_file_type(extension="." + row.extension, mime_type=row.mime_type) file_type = _resolve_file_type( detected_file_type=detected_file_type, - specified_type=mapping.get("type", "custom"), + specified_type=mapping.get("type"), strict_type_validation=strict_type_validation, ) @@ -163,7 +235,7 @@ def _build_from_local_file( def _build_from_remote_url( *, - mapping: Mapping[str, Any], + mapping: RemoteUrlMapping, tenant_id: str, transfer_method: FileTransferMethod, strict_type_validation: bool = False, @@ -235,7 +307,7 @@ def _build_from_remote_url( def _build_from_tool_file( *, - mapping: Mapping[str, Any], + mapping: ToolFileMapping, tenant_id: str, transfer_method: FileTransferMethod, strict_type_validation: bool = False, @@ -278,7 +350,7 @@ def _build_from_tool_file( def _build_from_datasource_file( *, - mapping: Mapping[str, Any], + mapping: DatasourceFileMapping, tenant_id: str, transfer_method: FileTransferMethod, strict_type_validation: bool = False, diff --git a/api/migrations/versions/2026_05_06_1200-8d4c2a1b9f03_add_human_input_upload_tables.py b/api/migrations/versions/2026_05_06_1200-8d4c2a1b9f03_add_human_input_upload_tables.py new file mode 100644 index 00000000000000..2ab6377547b27c --- /dev/null +++ b/api/migrations/versions/2026_05_06_1200-8d4c2a1b9f03_add_human_input_upload_tables.py @@ -0,0 +1,64 @@ +"""Add human input upload token and file association tables + +Revision ID: 8d4c2a1b9f03 +Revises: a4f2d8c9b731 +Create Date: 2026-05-06 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "8d4c2a1b9f03" +down_revision = "a4f2d8c9b731" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "human_input_form_upload_tokens", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("form_id", models.types.StringUUID(), nullable=False), + sa.Column("recipient_id", models.types.StringUUID(), nullable=False), + sa.Column("token", sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint("id", name="human_input_form_upload_tokens_pkey"), + sa.UniqueConstraint("token", name="human_input_form_upload_tokens_token_key"), + ) + with op.batch_alter_table("human_input_form_upload_tokens", schema=None) as batch_op: + batch_op.create_index("human_input_form_upload_tokens_form_id_idx", ["form_id"], unique=False) + + op.create_table( + "human_input_form_upload_files", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("form_id", models.types.StringUUID(), nullable=False), + sa.Column("upload_file_id", models.types.StringUUID(), nullable=False), + sa.Column("upload_token_id", models.types.StringUUID(), nullable=False), + sa.PrimaryKeyConstraint("id", name="human_input_form_upload_files_pkey"), + sa.UniqueConstraint("upload_file_id", name="human_input_form_upload_files_upload_file_id_key"), + ) + with op.batch_alter_table("human_input_form_upload_files", schema=None) as batch_op: + batch_op.create_index("human_input_form_upload_files_form_id_idx", ["form_id"], unique=False) + batch_op.create_index("human_input_form_upload_files_upload_token_id_idx", ["upload_token_id"], unique=False) + + +def downgrade(): + with op.batch_alter_table("human_input_form_upload_files", schema=None) as batch_op: + batch_op.drop_index("human_input_form_upload_files_upload_token_id_idx") + batch_op.drop_index("human_input_form_upload_files_form_id_idx") + op.drop_table("human_input_form_upload_files") + + with op.batch_alter_table("human_input_form_upload_tokens", schema=None) as batch_op: + batch_op.drop_index("human_input_form_upload_tokens_form_id_idx") + op.drop_table("human_input_form_upload_tokens") diff --git a/api/models/__init__.py b/api/models/__init__.py index 85be9ca3bd2439..35d5bba8566917 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -39,7 +39,7 @@ WorkflowTriggerStatus, ) from .execution_extra_content import ExecutionExtraContent, HumanInputContent -from .human_input import HumanInputForm +from .human_input import HumanInputForm, HumanInputFormUploadFile, HumanInputFormUploadToken from .model import ( AccountTrialAppRecord, ApiRequest, @@ -167,6 +167,8 @@ "ExternalKnowledgeBindings", "HumanInputContent", "HumanInputForm", + "HumanInputFormUploadFile", + "HumanInputFormUploadToken", "IconType", "InstalledApp", "InvitationCode", diff --git a/api/models/human_input.py b/api/models/human_input.py index 7447d3efcb3d5d..7b02e8d29d55cb 100644 --- a/api/models/human_input.py +++ b/api/models/human_input.py @@ -251,3 +251,55 @@ def new( access_token=_generate_token(), ) return recipient_model + + +class HumanInputFormUploadToken(DefaultFieldsMixin, Base): + """Upload authorization token bound to one human input form recipient. + + HITL upload tokens are intentionally separate from app/service bearer tokens. + The token is stored as an opaque random value so upload endpoints can perform + a direct lookup without entering the normal Web App authentication chain. + Upload ownership is resolved from the form's workflow run initiator instead + of being persisted on the token row itself. + """ + + __tablename__ = "human_input_form_upload_tokens" + __table_args__ = ( + sa.UniqueConstraint("token", name="human_input_form_upload_tokens_token_key"), + sa.Index("human_input_form_upload_tokens_form_id_idx", "form_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + form_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + recipient_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + token: Mapped[str] = mapped_column(sa.String(255), nullable=False) + + form: Mapped[HumanInputForm] = relationship( + "HumanInputForm", + uselist=False, + foreign_keys=[form_id], + primaryjoin="foreign(HumanInputFormUploadToken.form_id) == HumanInputForm.id", + lazy="raise", + ) + + +class HumanInputFormUploadFile(DefaultFieldsMixin, Base): + """Association between a human input form and a file uploaded through its token. + + Ownership remains on ``UploadFile`` itself; this table only records the + durable form/token/file linkage needed by Human Input flows. + """ + + __tablename__ = "human_input_form_upload_files" + __table_args__ = ( + sa.UniqueConstraint("upload_file_id", name="human_input_form_upload_files_upload_file_id_key"), + sa.Index("human_input_form_upload_files_form_id_idx", "form_id"), + sa.Index("human_input_form_upload_files_upload_token_id_idx", "upload_token_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + form_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + upload_file_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + upload_token_id: Mapped[str] = mapped_column(StringUUID, nullable=False) diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 2c8f0bd169fa8f..d6ed3b43d36a5c 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -12154,6 +12154,7 @@ Request payload for bulk downloading documents as a zip archive. | node_id | string | | Yes | | node_title | string | | Yes | | rendered_content | string | | Yes | +| submitted_data | object | | No | #### HumanInputFormSubmitPayload @@ -12313,6 +12314,12 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | JSONValue | | | | +#### JsonValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JsonValue | | | | + #### KnowledgeConfig | Name | Type | Description | Required | diff --git a/api/openapi/markdown/service-swagger.md b/api/openapi/markdown/service-swagger.md index 87dbe8c1ba56d4..35b5f9bfca9982 100644 --- a/api/openapi/markdown/service-swagger.md +++ b/api/openapi/markdown/service-swagger.md @@ -2463,7 +2463,7 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | action | string | | Yes | -| inputs | object | | Yes | +| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | #### IndexInfoResponse diff --git a/api/openapi/markdown/web-swagger.md b/api/openapi/markdown/web-swagger.md index 3676169d59ed9c..24baa83641de8b 100644 --- a/api/openapi/markdown/web-swagger.md +++ b/api/openapi/markdown/web-swagger.md @@ -409,6 +409,32 @@ Verify password reset token validity | 400 | Bad request - invalid token format | | | 401 | Invalid or expired token | | +### /form/human_input/files/remote-upload + +#### POST +##### Summary + +Upload one remote URL file for a HITL human input form + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /form/human_input/files/upload + +#### POST +##### Summary + +Upload one local file for a HITL human input form + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + ### /form/human_input/{form_token} #### GET @@ -461,6 +487,29 @@ Request body: | ---- | ----------- | | 200 | Success | +### /form/human_input/{form_token}/upload-token + +#### POST +##### Summary + +Issue an upload token for a human input form + +##### Description + +POST /api/form/human_input//upload-token + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + ### /login #### POST @@ -1188,6 +1237,19 @@ Returns Server-Sent Events stream. | email | string | | Yes | | language | string | | No | +#### HumanInputRemoteFileUploadPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| url | string (uri) | Remote file URL | Yes | + +#### HumanInputUploadTokenResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expires_at | integer | | Yes | +| upload_token | string | | Yes | + #### LicenseLimitationModel - enabled: whether this limit is enforced diff --git a/api/pyproject.toml b/api/pyproject.toml index 95fc38e2c8575c..c724349f469009 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -101,10 +101,7 @@ dify-trace-weave = { workspace = true } [tool.uv] default-groups = ["storage", "tools", "vdb-all", "trace-all"] package = false -override-dependencies = [ - "litellm>=1.83.7", - "pyarrow>=18.0.0", -] +override-dependencies = ["litellm>=1.83.7", "pyarrow>=18.0.0"] [dependency-groups] diff --git a/api/repositories/sqlalchemy_execution_extra_content_repository.py b/api/repositories/sqlalchemy_execution_extra_content_repository.py index 67f8795d3fdba2..65a1edbf2d197c 100644 --- a/api/repositories/sqlalchemy_execution_extra_content_repository.py +++ b/api/repositories/sqlalchemy_execution_extra_content_repository.py @@ -117,7 +117,7 @@ def _map_human_input_content( definition_payload["expiration_time"] = form.expiration_time form_definition = FormDefinition.model_validate(definition_payload) except ValueError: - logger.warning("Failed to load form definition for HumanInputContent(id=%s)", model.id) + logger.warning("Failed to load form definition for HumanInputContent(id=%s)", model.id, exc_info=True) return None node_title = form_definition.node_title or form.node_id display_in_ui = bool(form_definition.display_in_ui) @@ -125,21 +125,26 @@ def _map_human_input_content( submitted = form.submitted_at is not None or form.status == HumanInputFormStatus.SUBMITTED if not submitted: form_token = self._resolve_form_token(recipients_by_form_id.get(form.id, [])) + else: + form_token = None + form_definition_domain_model = HumanInputFormDefinition( + form_id=form.id, + node_id=form.node_id, + node_title=node_title, + form_content=form.rendered_content, + inputs=form_definition.inputs, + actions=form_definition.user_actions, + display_in_ui=display_in_ui, + form_token=form_token, + resolved_default_values=form_definition.default_values, + expiration_time=int(form.expiration_time.timestamp()), + ) + + if not submitted: return HumanInputContentDomainModel( workflow_run_id=model.workflow_run_id, submitted=False, - form_definition=HumanInputFormDefinition( - form_id=form.id, - node_id=form.node_id, - node_title=node_title, - form_content=form.rendered_content, - inputs=form_definition.inputs, - actions=form_definition.user_actions, - display_in_ui=display_in_ui, - form_token=form_token, - resolved_default_values=form_definition.default_values, - expiration_time=int(form.expiration_time.timestamp()), - ), + form_definition=form_definition_domain_model, ) selected_action_id = form.selected_action_id @@ -164,17 +169,20 @@ def _map_human_input_content( form.rendered_content, submitted_data, _extract_output_field_names(form_definition.form_content), + form_definition.inputs, ) return HumanInputContentDomainModel( workflow_run_id=model.workflow_run_id, - submitted=True, + submitted=submitted, + form_definition=form_definition_domain_model, form_submission_data=HumanInputFormSubmissionData( node_id=form.node_id, node_title=node_title, rendered_content=rendered_content, action_id=selected_action_id, action_text=action_text, + submitted_data=submitted_data, ), ) diff --git a/api/services/human_input_file_upload_service.py b/api/services/human_input_file_upload_service.py new file mode 100644 index 00000000000000..a9945d44784b7e --- /dev/null +++ b/api/services/human_input_file_upload_service.py @@ -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 repositories.factory import DifyAPIRepositoryFactory +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 = None, + ): + if isinstance(session_factory, Engine): + session_factory = sessionmaker(bind=session_factory) + self._session_maker = session_factory + self._workflow_run_repository = ( + workflow_run_repository or DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory) + ) + + def issue_upload_token(self, form_token: str) -> HumanInputUploadToken: + """Create an upload token for an active human input recipient token.""" + + with self._session_maker(expire_on_commit=False) 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(session) + 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) + + return HumanInputUploadToken(upload_token=upload_token, expires_at=form.expiration_time) + + 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(expire_on_commit=False) 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, session: Session) -> 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) diff --git a/api/services/human_input_service.py b/api/services/human_input_service.py index 76598d31ace575..1a74fa199a62c4 100644 --- a/api/services/human_input_service.py +++ b/api/services/human_input_service.py @@ -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 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,8 @@ from repositories.factory import DifyAPIRepositoryFactory from tasks.app_generate.workflow_execute_task import resume_app_execution +_file_access_controller = DatabaseFileAccessController() + class Form: def __init__(self, record: HumanInputFormRecord): @@ -82,7 +99,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 +107,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 +180,19 @@ def get_form_definition_by_token_for_console(self, form_token: str) -> Form | No 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 +201,17 @@ def submit_form_by_token( 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 +237,17 @@ def _ensure_not_submitted(self, form: Form) -> None: 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 +281,22 @@ def enqueue_resume(self, workflow_run_id: str) -> None: 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 +307,184 @@ def _is_globally_expired(self, form: Form, *, now: datetime | None = None) -> bo created_at = ensure_naive_utc(form.created_at) global_deadline = created_at + timedelta(seconds=global_timeout_seconds) return global_deadline <= current + + @staticmethod + def validate_human_input_submission( + *, + form_definition: FormDefinitionProtocol, + selected_action_id: str, + form_data: Mapping[str, Any], + ) -> None: + graphon_validate_human_input_submission( + inputs=form_definition.inputs, + user_actions=form_definition.user_actions, + selected_action_id=selected_action_id, + form_data=form_data, + ) + + @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] = {key: cast(JsonValue, value) for key, value in form_data.items()} + 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 cast(JsonValue, 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: + 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=cast(Sequence[Mapping[str, Any]], 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, + ) diff --git a/api/services/workflow_event_snapshot_service.py b/api/services/workflow_event_snapshot_service.py index 94f88f8c497917..dad7dff292ed68 100644 --- a/api/services/workflow_event_snapshot_service.py +++ b/api/services/workflow_event_snapshot_service.py @@ -24,11 +24,17 @@ ) 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 diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 1b0e10d7845170..6b0ca60cfd675e 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -59,7 +59,7 @@ 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 @@ WorkflowHashNotEqualError, WorkflowNotFoundError, ) +from services.human_input_service import HumanInputService from services.workflow.workflow_converter import WorkflowConverter from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError @@ -994,7 +995,7 @@ def get_human_input_form_preview( 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, @@ -1054,7 +1055,7 @@ def submit_human_input_form_preview( 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, @@ -1062,9 +1063,10 @@ def submit_human_input_form_preview( ) 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, ) @@ -1074,11 +1076,14 @@ def submit_human_input_form_preview( (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) @@ -1138,7 +1143,7 @@ def test_human_input_delivery( 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, @@ -1231,7 +1236,7 @@ def _load_email_recipients(form_id: str) -> list[DeliveryTestEmailRecipient]: 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, @@ -1263,8 +1268,8 @@ def _build_human_input_node( 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 diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py new file mode 100644 index 00000000000000..7c3ba2d9f63b31 --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from flask.testing import FlaskClient +from sqlalchemy import Engine +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 core.workflow.human_input_adapter import DeliveryMethodType +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 +from models.human_input import ( + HumanInputDelivery, + HumanInputForm, + HumanInputFormRecipient, + RecipientType, + StandaloneWebAppRecipientPayload, +) +from models.model import App, AppMode, CustomizeTokenStrategy, Site +from models.workflow import WorkflowRun, WorkflowType +from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository + + +def _create_app_with_site(session: Session) -> tuple[App, Account]: + tenant = Tenant(name="Test Tenant") + account = Account(name="Tester", email=f"tester-{uuid4()}@example.com") + session.add_all([tenant, account]) + session.flush() + + session.add( + TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + current=True, + role=TenantAccountRole.OWNER.value, + ) + ) + + app = App( + tenant_id=tenant.id, + name="Test App", + description="", + mode=AppMode.WORKFLOW.value, + icon_type="emoji", + icon="app", + icon_background="#ffffff", + enable_site=True, + enable_api=True, + created_by=account.id, + updated_by=account.id, + ) + session.add(app) + session.flush() + + site = Site( + app_id=app.id, + title="Test Site", + icon_type="emoji", + icon="robot", + icon_background="#ffffff", + description="desc", + default_language="en", + chat_color_theme="light", + chat_color_theme_inverted=False, + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, + code=f"code-{uuid4().hex[:8]}", + prompt_public=False, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + session.add(site) + session.flush() + return app, account + + +def _build_resumption_context(*, app: App, workflow_run: WorkflowRun, options: list[str]) -> WorkflowResumptionContext: + app_config = WorkflowUIBasedAppConfig( + tenant_id=app.tenant_id, + app_id=app.id, + app_mode=AppMode.WORKFLOW, + workflow_id=workflow_run.workflow_id, + ) + generate_entity = WorkflowAppGenerateEntity( + task_id="task-1", + app_config=app_config, + inputs={}, + files=[], + user_id=str(uuid4()), + stream=True, + invoke_from=InvokeFrom.WEB_APP, + call_depth=0, + workflow_execution_id=workflow_run.id, + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.variable_pool.add(("start", "options"), options) + return WorkflowResumptionContext( + generate_entity=_WorkflowGenerateEntityWrapper(entity=generate_entity), + serialized_graph_runtime_state=runtime_state.dumps(), + ) + + +def test_get_human_input_form_resolves_runtime_select_options( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + app, account = _create_app_with_site(db_session_with_containers) + workflow_run = WorkflowRun( + tenant_id=app.tenant_id, + app_id=app.id, + workflow_id=str(uuid4()), + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="v1", + graph=None, + inputs="{}", + status=WorkflowExecutionStatus.RUNNING, + outputs="{}", + error=None, + elapsed_time=0.0, + total_tokens=0, + total_steps=0, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=account.id, + created_at=datetime.now(UTC).replace(tzinfo=None), + ) + db_session_with_containers.add(workflow_run) + db_session_with_containers.flush() + + configured_input = SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=["configured"], + ), + ) + expiration_time = datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1) + form_definition = FormDefinition( + form_content="Choose", + rendered_content="Choose", + inputs=[configured_input], + user_actions=[UserActionConfig(id="approve", title="Approve")], + expiration_time=expiration_time, + ) + form = HumanInputForm( + tenant_id=app.tenant_id, + app_id=app.id, + workflow_run_id=workflow_run.id, + form_kind=HumanInputFormKind.RUNTIME, + node_id="human-node", + form_definition=form_definition.model_dump_json(), + rendered_content="Choose", + status=HumanInputFormStatus.WAITING, + expiration_time=expiration_time, + ) + db_session_with_containers.add(form) + db_session_with_containers.flush() + + delivery = HumanInputDelivery( + form_id=form.id, + delivery_method_type=DeliveryMethodType.WEBAPP, + channel_payload="{}", + ) + db_session_with_containers.add(delivery) + db_session_with_containers.flush() + + access_token = f"hitl{uuid4().hex[:18]}" + recipient = HumanInputFormRecipient( + form_id=form.id, + delivery_id=delivery.id, + recipient_type=RecipientType.STANDALONE_WEB_APP, + recipient_payload=StandaloneWebAppRecipientPayload().model_dump_json(), + access_token=access_token, + ) + db_session_with_containers.add(recipient) + db_session_with_containers.commit() + + context = _build_resumption_context( + app=app, + workflow_run=workflow_run, + options=["approve", "reject"], + ) + reason = HumanInputRequired( + form_id=form.id, + form_content="Choose", + inputs=[configured_input], + actions=[UserActionConfig(id="approve", title="Approve")], + node_id="human-node", + node_title="Human Input", + ) + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + workflow_run_repo = DifyAPISQLAlchemyWorkflowRunRepository( + session_maker=sessionmaker(bind=engine, expire_on_commit=False) + ) + workflow_run_repo.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=account.id, + state=context.dumps(), + pause_reasons=[reason], + ) + + monkeypatch.setattr( + "controllers.web.site.FeatureService.get_features", + lambda tenant_id: SimpleNamespace(can_replace_logo=False), + ) + + response = test_client_with_containers.get(f"/api/form/human_input/{access_token}") + + assert response.status_code == 200, response.get_data(as_text=True) + body = json.loads(response.get_data(as_text=True)) + assert body["inputs"][0]["option_source"]["type"] == "variable" + assert body["inputs"][0]["option_source"]["selector"] == ["start", "options"] + assert body["inputs"][0]["option_source"]["value"] == ["approve", "reject"] diff --git a/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py b/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py index 2a1638d1268cf0..dc8b3827c6f928 100644 --- a/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py +++ b/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py @@ -132,6 +132,7 @@ def create_human_input_message_fixture(db_session) -> HumanInputMessageFixture: status=HumanInputFormStatus.SUBMITTED, expiration_time=naive_utc_now() + timedelta(days=1), selected_action_id=action_id, + submitted_data='{"name": "Alice"}', ) db_session.add(form) db_session.flush() diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py index 1ffc84c1679010..3f3b3eae525364 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -642,7 +642,7 @@ def test_prefers_standalone_web_app_token_when_available( expiration_time = naive_utc_now() form_definition = FormDefinition( form_content="content", - inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + inputs=[ParagraphInputConfig(output_variable_name="name")], user_actions=[UserActionConfig(id="approve", title="Approve")], rendered_content="rendered", expiration_time=expiration_time, diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py index 7da6f4a32db1eb..4ce6bb98c3bf8e 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py @@ -5,6 +5,7 @@ from __future__ import annotations +import json from collections.abc import Generator from dataclasses import dataclass from datetime import timedelta @@ -174,11 +175,15 @@ def _create_submitted_form( action_id: str = "approve", action_title: str = "Approve", node_title: str = "Approval", + form_content: str = "content", + rendered_content: str | None = None, + inputs: list[dict] | None = None, + submitted_data: dict | None = None, ) -> HumanInputForm: expiration_time = naive_utc_now() + timedelta(days=1) form_definition = FormDefinition( - form_content="content", - inputs=[], + form_content=form_content, + inputs=inputs or [], user_actions=[UserActionConfig(id=action_id, title=action_title)], rendered_content="rendered", expiration_time=expiration_time, @@ -191,10 +196,12 @@ def _create_submitted_form( workflow_run_id=workflow_run_id, node_id="node-id", form_definition=form_definition.model_dump_json(), - rendered_content=f"Rendered {action_title}", + rendered_content=rendered_content or f"Rendered {action_title}", status=HumanInputFormStatus.SUBMITTED, expiration_time=expiration_time, selected_action_id=action_id, + submitted_data=None if submitted_data is None else json.dumps(submitted_data), + submitted_at=naive_utc_now(), ) session.add(form) session.flush() @@ -349,6 +356,127 @@ def test_groups_contents_by_message( # msg2 has no content assert result[1] == [] + def test_submitted_content_populates_submission_data_from_stored_form_data( + self, + db_session_with_containers: Session, + repository: SQLAlchemyExecutionExtraContentRepository, + test_scope: _TestScope, + ) -> None: + workflow_run_id = str(uuid4()) + conversation = _create_conversation(db_session_with_containers, test_scope) + msg = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id) + stored_submission_data = {"decision": "approve", "comment": "Looks good"} + form = _create_submitted_form( + db_session_with_containers, + test_scope, + workflow_run_id=workflow_run_id, + submitted_data=stored_submission_data, + ) + _create_human_input_content( + db_session_with_containers, + workflow_run_id=workflow_run_id, + message_id=msg.id, + form_id=form.id, + ) + db_session_with_containers.commit() + + result = repository.get_by_message_ids([msg.id]) + + content = result[0][0] + assert content.form_submission_data is not None + assert content.form_submission_data.submitted_data == stored_submission_data + + def test_submitted_content_exposes_select_and_file_form_data( + self, + db_session_with_containers: Session, + repository: SQLAlchemyExecutionExtraContentRepository, + test_scope: _TestScope, + ) -> None: + workflow_run_id = str(uuid4()) + conversation = _create_conversation(db_session_with_containers, test_scope) + msg = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id) + submitted_data = { + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/file.txt", + "filename": "file.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + "attachments": [ + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/first.txt", + "filename": "first.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/second.txt", + "filename": "second.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + ], + } + form = _create_submitted_form( + db_session_with_containers, + test_scope, + workflow_run_id=workflow_run_id, + form_content=( + "Decision: {{#$output.decision#}}\n" + "Attachment: {{#$output.attachment#}}\n" + "Attachments: {{#$output.attachments#}}" + ), + rendered_content=( + "Decision: {{#$output.decision#}}\n" + "Attachment: {{#$output.attachment#}}\n" + "Attachments: {{#$output.attachments#}}" + ), + inputs=[ + { + "type": "select", + "output_variable_name": "decision", + "option_source": {"type": "constant", "value": ["approve", "reject"]}, + }, + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + }, + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 3, + }, + ], + submitted_data=submitted_data, + ) + _create_human_input_content( + db_session_with_containers, + workflow_run_id=workflow_run_id, + message_id=msg.id, + form_id=form.id, + ) + db_session_with_containers.commit() + + result = repository.get_by_message_ids([msg.id]) + + content = result[0][0] + assert content.form_submission_data is not None + assert content.form_submission_data.submitted_data == submitted_data + assert content.form_submission_data.rendered_content == ( + "Decision: approve\nAttachment: [file]\nAttachments: [2 files]" + ) + def test_returns_unsubmitted_form_definition( self, db_session_with_containers: Session, diff --git a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py index 80f9083e815651..a46698a6b1228a 100644 --- a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py +++ b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py @@ -1,8 +1,12 @@ import json import uuid +from io import BytesIO from unittest.mock import MagicMock import pytest +from flask.testing import FlaskClient +from sqlalchemy import select +from sqlalchemy.orm import Session from core.workflow.human_input_adapter import ( EmailDeliveryConfig, @@ -11,14 +15,21 @@ ExternalRecipient, ) from graphon.enums import BuiltinNodeTypes -from graphon.nodes.human_input.entities import HumanInputNodeData +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.model import App, AppMode +from models.human_input import HumanInputForm, HumanInputFormRecipient, HumanInputFormUploadFile +from models.model import App, AppMode, UploadFile from models.workflow import Workflow, WorkflowType from services.workflow_service import WorkflowService -def _create_app_with_draft_workflow(session, *, delivery_method_id: uuid.UUID) -> tuple[App, Account]: +def _create_app_with_draft_workflow( + session: Session, + *, + delivery_method_id: uuid.UUID, + include_file_input: bool = False, +) -> tuple[App, Account]: tenant = Tenant(name="Test Tenant") account = Account(name="Tester", email="tester@example.com") session.add_all([tenant, account]) @@ -65,7 +76,7 @@ def _create_app_with_draft_workflow(session, *, delivery_method_id: uuid.UUID) - title="Human Input", delivery_methods=[email_method], form_content="Hello Human Input", - inputs=[], + inputs=[FileInputConfig(output_variable_name="attachment")] if include_file_input else [], user_actions=[], ).model_dump(mode="json") node_data["type"] = BuiltinNodeTypes.HUMAN_INPUT @@ -110,3 +121,71 @@ def test_human_input_delivery_test_sends_email( assert send_mock.call_count == 1 assert send_mock.call_args.kwargs["to"] == "recipient@example.com" + + +def test_human_input_delivery_test_form_accepts_file_upload( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + delivery_method_id = uuid.uuid4() + app, account = _create_app_with_draft_workflow( + db_session_with_containers, + delivery_method_id=delivery_method_id, + include_file_input=True, + ) + + monkeypatch.setattr("services.human_input_delivery_test_service.mail.is_inited", lambda: True) + monkeypatch.setattr("services.human_input_delivery_test_service.mail.send", MagicMock()) + + WorkflowService().test_human_input_delivery( + app_model=app, + account=account, + node_id="human-node", + delivery_method_id=str(delivery_method_id), + ) + + form = db_session_with_containers.scalar( + select(HumanInputForm) + .where( + HumanInputForm.app_id == app.id, + HumanInputForm.form_kind == HumanInputFormKind.DELIVERY_TEST, + HumanInputForm.workflow_run_id.is_(None), + ) + .limit(1) + ) + assert form is not None + recipient = db_session_with_containers.scalar( + select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id == form.id).limit(1) + ) + assert recipient is not None + assert recipient.access_token is not None + + token_response = test_client_with_containers.post(f"/api/form/human_input/{recipient.access_token}/upload-token") + assert token_response.status_code == 200 + upload_token = token_response.get_json()["upload_token"] + + upload_response = test_client_with_containers.post( + "/api/form/human_input/files/upload", + data={"file": (BytesIO(b"delivery test content"), "evidence.txt")}, + content_type="multipart/form-data", + headers={"Authorization": f"Bearer {upload_token}"}, + ) + + assert upload_response.status_code == 201, upload_response.get_data(as_text=True) + upload_file_id = upload_response.get_json()["id"] + + db_session_with_containers.expire_all() + upload_file = db_session_with_containers.get(UploadFile, upload_file_id) + assert upload_file is not None + assert upload_file.tenant_id == app.tenant_id + assert upload_file.created_by == account.id + link = db_session_with_containers.scalar( + select(HumanInputFormUploadFile) + .where( + HumanInputFormUploadFile.form_id == form.id, + HumanInputFormUploadFile.upload_file_id == upload_file_id, + ) + .limit(1) + ) + assert link is not None diff --git a/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py b/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py index 52ebc0131f28a4..6a9046acd4ac46 100644 --- a/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py +++ b/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py @@ -3,6 +3,7 @@ import pytest from sqlalchemy.orm import Session +from models.human_input import HumanInputFormStatus from services.message_service import MessageService from tests.test_containers_integration_tests.helpers.execution_extra_content import ( create_human_input_message_fixture, @@ -23,17 +24,55 @@ def test_pagination_returns_extra_contents(db_session_with_containers: Session): assert pagination.data message = pagination.data[0] - assert message.extra_contents == [ - { - "type": "human_input", - "workflow_run_id": fixture.message.workflow_run_id, - "submitted": True, - "form_submission_data": { - "node_id": fixture.form.node_id, - "node_title": fixture.node_title, - "rendered_content": fixture.form.rendered_content, - "action_id": fixture.action_id, - "action_text": fixture.action_text, - }, - } - ] + assert len(message.extra_contents) == 1 + content = message.extra_contents[0] + assert content["type"] == "human_input" + assert content["workflow_run_id"] == fixture.message.workflow_run_id + assert content["submitted"] is True + + form_submission_data = content["form_submission_data"] + assert form_submission_data["node_id"] == fixture.form.node_id + assert form_submission_data["node_title"] == fixture.node_title + assert form_submission_data["rendered_content"] == fixture.form.rendered_content + assert form_submission_data["action_id"] == fixture.action_id + assert form_submission_data["action_text"] == fixture.action_text + + form_definition = content["form_definition"] + assert form_definition["form_id"] == fixture.form.id + assert form_definition["node_id"] == fixture.form.node_id + assert form_definition["node_title"] == fixture.node_title + assert form_definition["form_content"] == fixture.form.rendered_content + + +@pytest.mark.usefixtures("flask_req_ctx_with_containers") +def test_pagination_returns_waiting_human_input_extra_contents(db_session_with_containers: Session): + fixture = create_human_input_message_fixture(db_session_with_containers) + fixture.form.status = HumanInputFormStatus.WAITING + fixture.form.selected_action_id = None + fixture.form.submitted_at = None + fixture.form.submitted_data = None + db_session_with_containers.commit() + + pagination = MessageService.pagination_by_first_id( + app_model=fixture.app, + user=fixture.account, + conversation_id=fixture.conversation.id, + first_id=None, + limit=10, + ) + + assert pagination.data + message = pagination.data[0] + assert len(message.extra_contents) == 1 + content = message.extra_contents[0] + assert content["type"] == "human_input" + assert content["workflow_run_id"] == fixture.message.workflow_run_id + assert content["submitted"] is False + assert "form_submission_data" not in content + + form_definition = content["form_definition"] + assert form_definition["form_id"] == fixture.form.id + assert form_definition["node_id"] == fixture.form.node_id + assert form_definition["node_title"] == fixture.node_title + assert form_definition["form_content"] == fixture.form.rendered_content + assert form_definition["display_in_ui"] is True diff --git a/api/tests/test_containers_integration_tests/services/test_message_service_extra_contents.py b/api/tests/test_containers_integration_tests/services/test_message_service_extra_contents.py index f2cb667204f367..d76a925d0e97e1 100644 --- a/api/tests/test_containers_integration_tests/services/test_message_service_extra_contents.py +++ b/api/tests/test_containers_integration_tests/services/test_message_service_extra_contents.py @@ -4,6 +4,7 @@ import pytest +from libs.helper import to_timestamp from models.enums import ConversationFromSource from models.model import Message from services import message_service @@ -47,17 +48,37 @@ def test_attach_message_extra_contents_assigns_serialized_payload(db_session_wit message_service.attach_message_extra_contents(messages) + form = fixture.form + assert messages[0].extra_contents == [ { "type": "human_input", "workflow_run_id": fixture.message.workflow_run_id, "submitted": True, + "form_definition": { + "form_id": form.id, + "node_id": form.node_id, + "node_title": "Approval", + "form_content": "Rendered block", + "inputs": [], + "actions": [ + { + "id": "approve", + "title": "Approve request", + "button_style": "default", + } + ], + "display_in_ui": True, + "resolved_default_values": {}, + "expiration_time": to_timestamp(form.expiration_time), + }, "form_submission_data": { "node_id": fixture.form.node_id, "node_title": fixture.node_title, "rendered_content": fixture.form.rendered_content, "action_id": fixture.action_id, "action_text": fixture.action_text, + "submitted_data": {"name": "Alice"}, }, } ] diff --git a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py new file mode 100644 index 00000000000000..f5268515a0c931 --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from sqlalchemy import Engine, delete +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 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 +from models.model import AppMode +from models.workflow import WorkflowRun +from repositories.entities.workflow_pause import WorkflowPauseEntity +from services.workflow_event_snapshot_service import _build_snapshot_events + + +@dataclass(frozen=True) +class _FakePauseEntity(WorkflowPauseEntity): + pause_id: str + workflow_run_id: str + paused_at_value: datetime + pause_reasons: Sequence[HumanInputRequired] + + @property + def id(self) -> str: + return self.pause_id + + @property + def workflow_execution_id(self) -> str: + return self.workflow_run_id + + def get_state(self) -> bytes: + raise AssertionError("state is not required for snapshot tests") + + @property + def resumed_at(self) -> datetime | None: + return None + + @property + def paused_at(self) -> datetime: + return self.paused_at_value + + def get_pause_reasons(self) -> Sequence[HumanInputRequired]: + return self.pause_reasons + + +def _build_resumption_context(workflow_run_id: str) -> WorkflowResumptionContext: + app_config = WorkflowUIBasedAppConfig( + tenant_id=str(uuid4()), + app_id=str(uuid4()), + app_mode=AppMode.WORKFLOW, + workflow_id=str(uuid4()), + ) + generate_entity = WorkflowAppGenerateEntity( + task_id="task-1", + app_config=app_config, + inputs={}, + files=[], + user_id=str(uuid4()), + stream=True, + invoke_from=InvokeFrom.EXPLORE, + call_depth=0, + workflow_execution_id=workflow_run_id, + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.variable_pool.add(("start", "options"), ["approve", "reject"]) + wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) + return WorkflowResumptionContext( + generate_entity=wrapper, + serialized_graph_runtime_state=runtime_state.dumps(), + ) + + +def _build_workflow_run(workflow_run_id: str) -> WorkflowRun: + return WorkflowRun( + id=workflow_run_id, + tenant_id=str(uuid4()), + app_id=str(uuid4()), + workflow_id=str(uuid4()), + type="workflow", + triggered_from="app-run", + version="v1", + graph=None, + inputs="{}", + status=WorkflowExecutionStatus.PAUSED, + outputs="{}", + error=None, + elapsed_time=0.0, + total_tokens=0, + total_steps=0, + created_by_role=CreatorUserRole.END_USER, + created_by=str(uuid4()), + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +def test_build_snapshot_events_resolves_variable_select_options(db_session_with_containers: Session) -> None: + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + test_tenant_id = str(uuid4()) + test_app_id = str(uuid4()) + workflow_run_id = str(uuid4()) + form = HumanInputForm( + tenant_id=test_tenant_id, + app_id=test_app_id, + workflow_run_id=workflow_run_id, + node_id="node-id", + form_definition='{"display_in_ui": true}', + rendered_content="Rendered", + status=HumanInputFormStatus.WAITING, + expiration_time=(datetime.now(UTC) + timedelta(hours=1)).replace(tzinfo=None), + ) + db_session_with_containers.add(form) + db_session_with_containers.commit() + db_session_with_containers.refresh(form) + + reason = HumanInputRequired( + form_id=form.id, + form_content="Rendered", + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ], + actions=[UserActionConfig(id="approve", title="Approve")], + node_id="node-id", + node_title="Human Input", + ) + pause_entity = _FakePauseEntity( + pause_id=str(uuid4()), + workflow_run_id=workflow_run_id, + paused_at_value=datetime.now(UTC), + pause_reasons=[reason], + ) + + session_maker = sessionmaker(bind=engine, expire_on_commit=False) + events = _build_snapshot_events( + workflow_run=_build_workflow_run(workflow_run_id), + node_snapshots=[], + task_id="task-1", + message_context=None, + pause_entity=pause_entity, + resumption_context=_build_resumption_context(workflow_run_id), + session_maker=session_maker, + ) + + human_input_events = [event for event in events if event.get("event") == "human_input_required"] + assert len(human_input_events) == 1 + assert human_input_events[0]["data"]["inputs"][0]["option_source"]["value"] == ["approve", "reject"] + + db_session_with_containers.execute(delete(HumanInputForm).where(HumanInputForm.id == form.id)) + db_session_with_containers.commit() diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py index 58274f16888be4..78e1b0c46fa871 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py @@ -13,7 +13,6 @@ from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig -from graphon.nodes.human_input.enums import FormInputType from libs import login as login_lib from models.account import Account, AccountStatus, TenantAccountRole from models.workflow import WorkflowRun @@ -66,7 +65,7 @@ def test_pause_details_returns_backstage_input_url(app: Flask, monkeypatch: pyte reason = HumanInputRequired( form_id="form-1", form_content="content", - inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + inputs=[ParagraphInputConfig(output_variable_name="name")], actions=[UserActionConfig(id="approve", title="Approve")], node_id="node-1", node_title="Ask Name", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py index ff668ac60ada6b..b2920f93d342eb 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -593,7 +593,11 @@ def __exit__(self, exc_type, exc, tb): form_id="form-1", form_content="Rendered", inputs=[ - ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="field", default=None), + ParagraphInputConfig( + type=FormInputType.PARAGRAPH, + output_variable_name="field", + default=None, + ), ], actions=[UserActionConfig(id="approve", title="Approve")], display_in_ui=True, @@ -607,7 +611,7 @@ def __exit__(self, exc_type, exc, tb): paused_nodes=["node-id"], ) - runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0) + runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0, variable_pool=VariablePool()) responses = converter.workflow_pause_to_stream_response( event=queue_event, task_id="task", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py index 5d1c4b4e26a7ff..ce000ab5a25993 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py @@ -12,6 +12,7 @@ from flask import Flask from werkzeug.exceptions import NotFound +from controllers.common.human_input import HumanInputFormSubmitPayload from controllers.service_api.app.human_input_form import WorkflowHumanInputFormApi from models.human_input import RecipientType from tests.unit_tests.controllers.service_api.conftest import _unwrap @@ -20,7 +21,7 @@ class TestWorkflowHumanInputFormApi: def test_get_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: definition = SimpleNamespace( - model_dump=lambda: { + model_dump=lambda **_kwargs: { "rendered_content": "Rendered form content", "inputs": [{"output_variable_name": "name"}], "default_values": {"name": "Alice", "age": 30, "meta": {"k": "v"}}, @@ -36,6 +37,9 @@ def test_get_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: ) service_mock = Mock() service_mock.get_form_by_token.return_value = form + service_mock.resolve_form_inputs.return_value = [ + SimpleNamespace(model_dump=lambda **_kwargs: {"output_variable_name": "name"}) + ] workflow_module = sys.modules["controllers.service_api.app.human_input_form"] monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) @@ -56,8 +60,54 @@ def test_get_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: "expiration_time": int(form.expiration_time.timestamp()), } service_mock.get_form_by_token.assert_called_once_with("token-1") + service_mock.resolve_form_inputs.assert_called_once_with(form) service_mock.ensure_form_active.assert_called_once_with(form) + def test_get_resolves_runtime_select_values(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + definition = SimpleNamespace( + model_dump=lambda **_kwargs: { + "rendered_content": "Rendered form content", + "inputs": [ + { + "output_variable_name": "decision", + "option_source": {"type": "variable", "selector": ["start", "options"], "value": []}, + } + ], + "default_values": {}, + "user_actions": [{"id": "approve", "title": "Approve"}], + } + ) + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + get_definition=lambda: definition, + ) + resolved_input = SimpleNamespace( + model_dump=lambda **_kwargs: { + "output_variable_name": "decision", + "option_source": {"type": "variable", "selector": ["start", "options"], "value": ["approve", "reject"]}, + } + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + service_mock.resolve_form_inputs.return_value = [resolved_input] + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/form/human_input/token-1", method="GET"): + response = handler(api, app_model=app_model, form_token="token-1") + + payload = json.loads(response.get_data(as_text=True)) + assert payload["inputs"][0]["option_source"]["value"] == ["approve", "reject"] + service_mock.resolve_form_inputs.assert_called_once_with(form) + def test_get_form_not_in_app(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: form = SimpleNamespace( app_id="another-app", @@ -146,6 +196,71 @@ def test_post_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None submission_end_user_id="end-user-1", ) + def test_post_accepts_select_file_and_file_list_inputs(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.post) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + end_user = SimpleNamespace(id="end-user-1") + inputs = { + "decision": "approve", + "attachment": { + "transfer_method": "local_file", + "upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e", + "type": "document", + }, + "attachments": [ + { + "transfer_method": "local_file", + "upload_file_id": "1a77f0df-c0e6-461c-987c-e72526f341ee", + "type": "document", + }, + { + "transfer_method": "remote_url", + "url": "https://example.com/report.pdf", + "type": "document", + }, + ], + } + + with app.test_request_context( + "/form/human_input/token-1", + method="POST", + json={"inputs": inputs, "action": "approve", "user": "external-1"}, + ): + response, status = handler(api, app_model=app_model, end_user=end_user, form_token="token-1") + + assert response == {} + assert status == 200 + service_mock.submit_form_by_token.assert_called_once_with( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token-1", + selected_action_id="approve", + form_data=inputs, + submission_end_user_id="end-user-1", + ) + + def test_submit_payload_schema_documents_select_file_and_file_list_inputs(self) -> None: + schema = HumanInputFormSubmitPayload.model_json_schema() + + inputs_schema = schema["properties"]["inputs"] + assert "select input" in inputs_schema["description"] + examples = inputs_schema["examples"] + assert examples[0]["decision"] == "approve" + assert examples[0]["attachment"]["transfer_method"] == "local_file" + assert examples[0]["attachment"]["upload_file_id"] == "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e" + assert examples[0]["attachments"][1]["transfer_method"] == "remote_url" + @pytest.mark.parametrize( "recipient_type", [ diff --git a/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py b/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py new file mode 100644 index 00000000000000..5786748ba3cc61 --- /dev/null +++ b/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py @@ -0,0 +1,185 @@ +"""Unit tests for HITL human input file upload endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from io import BytesIO +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +import controllers.web.human_input_file_upload as upload_module +from controllers.common.errors import NoFileUploadedError +from controllers.web.human_input_file_upload import ( + HumanInputFileUploadApi, + HumanInputRemoteFileUploadApi, + InvalidUploadTokenForbiddenError, + InvalidUploadTokenUnauthorizedError, +) + + +@pytest.fixture +def app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + return app + + +def _upload_context() -> SimpleNamespace: + return SimpleNamespace( + form_id="form-1", + upload_token_id="token-row-1", + owner=SimpleNamespace(id="owner-1", current_tenant_id="tenant-1"), + ) + + +def _upload_file() -> SimpleNamespace: + return SimpleNamespace( + id="file-1", + name="sample.txt", + size=7, + extension="txt", + mime_type="text/plain", + created_by="end-user-1", + created_at=datetime(2024, 1, 1), + tenant_id="tenant-1", + source_url="signed-source-url", + ) + + +def test_local_upload_requires_authorization_before_reading_files(app: Flask) -> None: + data = {"file": (BytesIO(b"content"), "sample.txt")} + + with app.test_request_context( + "/api/form/human_input/files/upload", + method="POST", + data=data, + content_type="multipart/form-data", + ): + with pytest.raises(InvalidUploadTokenUnauthorizedError): + HumanInputFileUploadApi().post() + + +def test_local_upload_ignores_source_and_records_form_file_link(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None: + service = MagicMock() + service.validate_upload_token.return_value = _upload_context() + monkeypatch.setattr(upload_module, "HumanInputFileUploadService", lambda engine: service) + + file_service = MagicMock() + file_service.upload_file.return_value = _upload_file() + file_service_cls = MagicMock(return_value=file_service) + monkeypatch.setattr(upload_module, "FileService", file_service_cls) + monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object())) + + data = { + "file": (BytesIO(b"content"), "sample.txt"), + "source": "datasets", + } + with app.test_request_context( + "/api/form/human_input/files/upload", + method="POST", + headers={"Authorization": "bearer hitl_upload_token-1"}, + data=data, + content_type="multipart/form-data", + ): + result, status = HumanInputFileUploadApi().post() + + assert status == 201 + assert result["id"] == "file-1" + file_service.upload_file.assert_called_once() + assert file_service.upload_file.call_args.kwargs["source"] is None + assert file_service.upload_file.call_args.kwargs["user"].id == "owner-1" + service.record_upload_file.assert_called_once_with( + context=service.validate_upload_token.return_value, + file_id="file-1", + ) + + +def test_local_upload_missing_file_raises_after_valid_token(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None: + service = MagicMock() + service.validate_upload_token.return_value = _upload_context() + monkeypatch.setattr(upload_module, "HumanInputFileUploadService", lambda engine: service) + monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object())) + + with app.test_request_context( + "/api/form/human_input/files/upload", + method="POST", + headers={"Authorization": "bearer hitl_upload_token-1"}, + content_type="multipart/form-data", + ): + with pytest.raises(NoFileUploadedError): + HumanInputFileUploadApi().post() + + service.validate_upload_token.assert_called_once_with("hitl_upload_token-1") + + +def test_remote_upload_validates_token_before_fetching_remote_url(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None: + service = MagicMock() + service.validate_upload_token.side_effect = InvalidUploadTokenForbiddenError() + monkeypatch.setattr(upload_module, "HumanInputFileUploadService", lambda engine: service) + monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object())) + ssrf_proxy = MagicMock() + monkeypatch.setattr(upload_module, "ssrf_proxy", ssrf_proxy) + + with app.test_request_context( + "/api/form/human_input/files/remote-upload", + method="POST", + headers={"Authorization": "Bearer hitl_upload_token-1"}, + json={"url": "https://example.com/file.txt"}, + ): + with pytest.raises(InvalidUploadTokenForbiddenError): + HumanInputRemoteFileUploadApi().post() + + ssrf_proxy.head.assert_not_called() + ssrf_proxy.get.assert_not_called() + + +def test_remote_upload_records_form_file_link(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None: + service = MagicMock() + service.validate_upload_token.return_value = _upload_context() + monkeypatch.setattr(upload_module, "HumanInputFileUploadService", lambda engine: service) + monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object())) + + response = MagicMock() + response.status_code = 200 + response.content = b"remote" + response.request.method = "GET" + ssrf_proxy = MagicMock() + ssrf_proxy.head.return_value = response + monkeypatch.setattr(upload_module, "ssrf_proxy", ssrf_proxy) + monkeypatch.setattr( + upload_module.helpers, + "guess_file_info_from_response", + lambda _response: SimpleNamespace(filename="sample.txt", extension="txt", mimetype="text/plain", size=6), + ) + + file_service = MagicMock() + file_service.upload_file.return_value = _upload_file() + file_service_cls = MagicMock(return_value=file_service) + file_service_cls.is_file_size_within_limit.return_value = True + monkeypatch.setattr(upload_module, "FileService", file_service_cls) + monkeypatch.setattr( + upload_module.file_helpers, + "get_signed_file_url", + lambda upload_file_id: f"signed:{upload_file_id}", + ) + + with app.test_request_context( + "/api/form/human_input/files/remote-upload", + method="POST", + headers={"Authorization": "Bearer hitl_upload_token-1"}, + json={"url": "https://example.com/file.txt"}, + ): + result, status = HumanInputRemoteFileUploadApi().post() + + assert status == 201 + assert result["url"] == "signed:file-1" + file_service.upload_file.assert_called_once() + assert file_service.upload_file.call_args.kwargs["source_url"] == "https://example.com/file.txt" + assert file_service.upload_file.call_args.kwargs["user"].id == "owner-1" + service.record_upload_file.assert_called_once_with( + context=service.validate_upload_token.return_value, + file_id="file-1", + ) diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index 5f2dc19aab4acb..52b88e22d7a0a1 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -15,10 +15,13 @@ 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 models.human_input import RecipientType from services.human_input_service import FormExpiredError HumanInputFormApi = human_input_module.HumanInputFormApi +HumanInputFormUploadTokenApi = human_input_module.HumanInputFormUploadTokenApi TenantStatus = human_input_module.TenantStatus @@ -63,7 +66,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): expiration_time = datetime(2099, 1, 1, tzinfo=UTC) class _FakeDefinition: - def model_dump(self): + def model_dump(self, mode: str | None = None): return { "form_content": "Raw content", "rendered_content": "Rendered {{#$output.name#}}", @@ -117,6 +120,8 @@ def get_definition(self): # Patch service to return fake form. service_mock = MagicMock() service_mock.get_form_by_token.return_value = form + resolved_input = ParagraphInputConfig(output_variable_name="name") + service_mock.resolve_form_inputs.return_value = [resolved_input] monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock) # Patch db session. @@ -142,7 +147,7 @@ def get_definition(self): "expiration_time", } assert body["form_content"] == "Rendered {{#$output.name#}}" - assert body["inputs"] == [{"type": "text", "output_variable_name": "name", "default": None}] + assert body["inputs"] == [resolved_input.model_dump(mode="json")] assert body["resolved_default_values"] == {"name": "Alice", "age": "30", "meta": '{"k": "v"}'} assert body["user_actions"] == [{"id": "approve", "title": "Approve", "button_style": "default"}] assert body["expiration_time"] == int(expiration_time.timestamp()) @@ -180,13 +185,138 @@ def get_definition(self): limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10") +def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, app: Flask): + """GET returns variable-backed select options resolved from runtime state.""" + + expiration_time = datetime(2099, 1, 1, tzinfo=UTC) + configured_inputs = [ + { + "type": "select", + "output_variable_name": "decision", + "option_source": { + "type": "variable", + "selector": ["start", "options"], + "value": ["configured"], + }, + } + ] + runtime_inputs = [ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=["approve", "reject"], + ), + ) + ] + + class _FakeDefinition: + def model_dump(self, mode: str | None = None): + return { + "form_content": "Raw content", + "rendered_content": "Rendered", + "inputs": configured_inputs, + "default_values": {}, + "user_actions": [], + } + + class _FakeForm: + def __init__(self, expiration: datetime): + self.workflow_run_id = "workflow-1" + self.app_id = "app-1" + self.tenant_id = "tenant-1" + self.recipient_type = RecipientType.STANDALONE_WEB_APP + self.expiration_time = expiration + + def get_definition(self): + return _FakeDefinition() + + limiter_mock = MagicMock() + limiter_mock.is_rate_limited.return_value = False + monkeypatch.setattr(human_input_module, "_FORM_ACCESS_RATE_LIMITER", limiter_mock) + monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10") + + tenant = SimpleNamespace( + id="tenant-1", + status=TenantStatus.NORMAL, + plan="basic", + custom_config_dict={}, + ) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True) + site_model = SimpleNamespace( + title="My Site", + icon_type="emoji", + icon="robot", + icon_background="#fff", + description="desc", + default_language="en", + chat_color_theme="light", + chat_color_theme_inverted=False, + copyright=None, + privacy_policy=None, + custom_disclaimer=None, + prompt_public=False, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + + form = _FakeForm(expiration_time) + service_mock = MagicMock() + service_mock.get_form_by_token.return_value = form + service_mock.resolve_form_inputs.return_value = runtime_inputs + monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock) + monkeypatch.setattr(human_input_module, "db", _FakeDB(_FakeSession({"App": app_model, "Site": site_model}))) + monkeypatch.setattr( + site_module.FeatureService, + "get_features", + lambda tenant_id: SimpleNamespace(can_replace_logo=True), + ) + + with app.test_request_context("/api/form/human_input/token-1", method="GET"): + response = HumanInputFormApi().get("token-1") + + body = json.loads(response.get_data(as_text=True)) + assert body["inputs"] == [input_config.model_dump(mode="json") for input_config in runtime_inputs] + service_mock.resolve_form_inputs.assert_called_once_with(form) + + +def test_create_upload_token_returns_token_and_form_expiration(monkeypatch: pytest.MonkeyPatch, app: Flask): + """POST returns a HITL upload token for an active form token.""" + + expiration_time = datetime(2099, 1, 1, tzinfo=UTC) + service_mock = MagicMock() + service_mock.issue_upload_token.return_value = SimpleNamespace( + upload_token="hitl_upload_token-1", + expires_at=expiration_time, + ) + monkeypatch.setattr(human_input_module, "HumanInputFileUploadService", lambda engine: service_mock) + monkeypatch.setattr(human_input_module, "db", SimpleNamespace(engine=object())) + + limiter_mock = MagicMock() + limiter_mock.is_rate_limited.return_value = False + monkeypatch.setattr(human_input_module, "_FORM_UPLOAD_TOKEN_RATE_LIMITER", limiter_mock) + monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10") + + with app.test_request_context("/api/form/human_input/token-1/upload-token", method="POST"): + result, status = HumanInputFormUploadTokenApi().post("token-1") + + assert status == 200 + assert result == { + "upload_token": "hitl_upload_token-1", + "expires_at": int(expiration_time.timestamp()), + } + service_mock.issue_upload_token.assert_called_once_with("token-1") + limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10") + + def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask): """GET returns form payload for backstage token.""" expiration_time = datetime(2099, 1, 2, tzinfo=UTC) class _FakeDefinition: - def model_dump(self): + def model_dump(self, mode: str | None = None): return { "form_content": "Raw content", "rendered_content": "Rendered", @@ -237,6 +367,7 @@ def get_definition(self): service_mock = MagicMock() service_mock.get_form_by_token.return_value = form + service_mock.resolve_form_inputs.return_value = [] monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock) db_stub = _FakeDB(_FakeSession({"WorkflowRun": workflow_run, "App": app_model, "Site": site_model})) @@ -305,7 +436,7 @@ def test_get_form_raises_forbidden_when_site_missing(monkeypatch: pytest.MonkeyP expiration_time = datetime(2099, 1, 3, tzinfo=UTC) class _FakeDefinition: - def model_dump(self): + def model_dump(self, mode: str | None = None): return { "form_content": "Raw content", "rendered_content": "Rendered", diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py index 1bef6f69cdd9a1..9df351fb7aa194 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py @@ -7,6 +7,7 @@ from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.variables.segments import StringSegment def _build_converter(): @@ -63,6 +64,37 @@ def test_human_input_form_filled_stream_response_contains_rendered_content(): assert resp.data.action_id == "Approve" +def test_human_input_form_filled_stream_response_serializes_submitted_data(): + converter = _build_converter() + converter.workflow_start_to_stream_response( + task_id="task-1", + workflow_run_id="run-1", + workflow_id="wf-1", + reason=WorkflowStartReason.INITIAL, + ) + + queue_event = QueueHumanInputFormFilledEvent( + node_execution_id="exec-1", + node_id="node-1", + node_type="human-input", + node_title="Human Input", + rendered_content="# Title\nvalue", + action_id="Approve", + action_text="Approve", + submitted_data={ + "decision": StringSegment(value="approve"), + "comment": StringSegment(value="looks good"), + }, + ) + + resp = converter.human_input_form_filled_to_stream_response(event=queue_event, task_id="task-1") + + assert resp.data.submitted_data == { + "decision": "approve", + "comment": "looks good", + } + + def test_human_input_form_timeout_stream_response_contains_timeout_metadata(): converter = _build_converter() converter.workflow_start_to_stream_response( diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py index 3949c41eae6fe7..77cd81db58e376 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py @@ -9,6 +9,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.app.entities.queue_entities import ( QueueAgentLogEvent, + QueueHumanInputFormFilledEvent, QueueIterationCompletedEvent, QueueLoopCompletedEvent, QueueNodeExceptionEvent, @@ -30,6 +31,7 @@ NodeRunAgentLogEvent, NodeRunExceptionEvent, NodeRunFailedEvent, + NodeRunHumanInputFormFilledEvent, NodeRunIterationSucceededEvent, NodeRunLoopFailedEvent, NodeRunRetryEvent, @@ -39,6 +41,7 @@ ) from graphon.node_events import NodeRunResult from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.variables.segments import StringSegment from graphon.variables.variables import StringVariable @@ -363,6 +366,42 @@ def publish(self, event, publish_from): assert any(isinstance(event, QueueIterationCompletedEvent) for event in published) assert any(isinstance(event, QueueLoopCompletedEvent) for event in published) + def test_handle_human_input_form_filled_event_preserves_submitted_data(self): + published: list[object] = [] + + class _QueueManager: + def publish(self, event, publish_from): + published.append(event) + + runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") + graph_runtime_state = GraphRuntimeState( + variable_pool=VariablePool.from_bootstrap( + system_variables=default_system_variables(), + user_inputs={}, + environment_variables=[], + ), + start_at=0.0, + ) + workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) + + runner._handle_event( + workflow_entry, + NodeRunHumanInputFormFilledEvent( + id="exec", + node_id="node", + node_type=BuiltinNodeTypes.HUMAN_INPUT, + node_title="Human Input", + rendered_content="content", + action_id="approve", + action_text="Approve", + submitted_data={"decision": StringSegment(value="approve")}, + ), + ) + + queue_event = published[-1] + assert isinstance(queue_event, QueueHumanInputFormFilledEvent) + assert queue_event.submitted_data == {"decision": StringSegment(value="approve")} + @pytest.mark.parametrize( ("event_factory", "queue_event_cls"), [ diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py index 72a46a74c96b1b..319e603b3518b2 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py @@ -14,8 +14,14 @@ 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 ParagraphInputConfig, UserActionConfig -from graphon.nodes.human_input.enums import FormInputType +from graphon.nodes.human_input.entities import ( + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + UserActionConfig, +) +from graphon.nodes.human_input.enums import ValueSourceType +from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account from models.human_input import RecipientType @@ -156,9 +162,7 @@ def __exit__(self, exc_type, exc, tb): reason = HumanInputRequired( form_id="form-1", form_content="Rendered", - inputs=[ - ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="field", default=None), - ], + inputs=[ParagraphInputConfig(output_variable_name="field")], actions=[UserActionConfig(id="approve", title="Approve")], node_id="node-id", node_title="Human Step", @@ -169,7 +173,7 @@ def __exit__(self, exc_type, exc, tb): paused_nodes=["node-id"], ) - runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) responses = converter.workflow_pause_to_stream_response( event=queue_event, task_id="task", @@ -193,3 +197,70 @@ def __exit__(self, exc_type, exc, tb): assert hi_resp.data.display_in_ui is True assert hi_resp.data.form_token == "backstage-token" assert hi_resp.data.expiration_time == int(expiration_time.timestamp()) + + +def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatch: pytest.MonkeyPatch): + converter = _build_converter() + converter.workflow_start_to_stream_response( + task_id="task", + workflow_run_id="run-id", + workflow_id="workflow-id", + reason=WorkflowStartReason.INITIAL, + ) + + expiration_time = datetime(2024, 1, 1, tzinfo=UTC) + + class _FakeSession: + def execute(self, _stmt): + return [("form-1", expiration_time, '{"display_in_ui": true}')] + + def scalars(self, _stmt): + return [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession()) + monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object())) + + reason = HumanInputRequired( + form_id="form-1", + form_content="Rendered", + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ], + actions=[UserActionConfig(id="approve", title="Approve")], + node_id="node-id", + node_title="Human Step", + ) + queue_event = QueueWorkflowPausedEvent( + reasons=[reason], + outputs={}, + paused_nodes=["node-id"], + ) + + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.variable_pool.add(("start", "options"), ["approve", "reject"]) + responses = converter.workflow_pause_to_stream_response( + event=queue_event, + task_id="task", + graph_runtime_state=runtime_state, + ) + + assert isinstance(responses[0], HumanInputRequiredResponse) + hi_resp = responses[0] + assert hi_resp.data.inputs[0].option_source.value == ["approve", "reject"] + + assert isinstance(responses[-1], WorkflowPauseStreamResponse) + pause_resp = responses[-1] + assert pause_resp.data.reasons[0]["inputs"][0]["option_source"]["value"] == ["approve", "reject"] diff --git a/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py b/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py index 26421799921c7c..d0849e7b881de1 100644 --- a/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py +++ b/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py @@ -5,7 +5,6 @@ HumanInputFormSubmissionData, ) from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig -from graphon.nodes.human_input.enums import FormInputType from models.execution_extra_content import ExecutionContentType @@ -16,7 +15,7 @@ def test_human_input_content_defaults_and_domain_alias() -> None: node_id="node-1", node_title="Human Input", form_content="Please confirm", - inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="answer")], + inputs=[ParagraphInputConfig(output_variable_name="answer")], actions=[UserActionConfig(id="confirm", title="Confirm")], resolved_default_values={"answer": "yes"}, expiration_time=1_700_000_000, @@ -27,6 +26,7 @@ def test_human_input_content_defaults_and_domain_alias() -> None: rendered_content="Please confirm", action_id="confirm", action_text="Confirm", + submitted_data={"answer": "yes"}, ) # Act @@ -42,4 +42,5 @@ def test_human_input_content_defaults_and_domain_alias() -> None: assert content.type == ExecutionContentType.HUMAN_INPUT assert content.form_definition is form_definition assert content.form_submission_data is submission_data + assert content.form_submission_data.submitted_data == {"answer": "yes"} assert ExecutionExtraContentDomainModel is HumanInputContent diff --git a/api/tests/unit_tests/core/repositories/test_human_input_repository.py b/api/tests/unit_tests/core/repositories/test_human_input_repository.py index 418537675d7362..edd8be861847b0 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_repository.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_repository.py @@ -586,6 +586,73 @@ def test_mark_submitted_updates_and_raises_when_missing(monkeypatch: pytest.Monk assert record.submitted_data == {"k": "v"} +def test_mark_submitted_serializes_select_and_file_payloads(monkeypatch: pytest.MonkeyPatch) -> None: + fixed_now = datetime(2024, 1, 1, 0, 0, 0) + monkeypatch.setattr("core.repositories.human_input_repository.naive_utc_now", lambda: fixed_now) + + form = _DummyForm( + id="f-complex", + workflow_run_id=None, + node_id="node", + tenant_id="tenant", + app_id="app", + form_definition=_make_form_definition_json(include_expiration_time=True), + rendered_content="

x

", + expiration_time=fixed_now, + ) + recipient = _DummyRecipient( + id="r-complex", + form_id=form.id, + recipient_type=RecipientType.CONSOLE, + access_token="tok", + ) + session = _FakeSession(forms={form.id: form}, recipients={recipient.id: recipient}) + _patch_session_factory(monkeypatch, session) + + payload = { + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/file.txt", + "filename": "file.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + "attachments": [ + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/first.txt", + "filename": "first.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/second.txt", + "filename": "second.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + ], + } + + repo = HumanInputFormSubmissionRepository() + record = repo.mark_submitted( + form_id=form.id, + recipient_id=recipient.id, + selected_action_id="approve", + form_data=payload, + submission_user_id="user-1", + submission_end_user_id="end-user-1", + ) + + assert json.loads(form.submitted_data or "") == payload + assert record.submitted_data == payload + + def test_mark_timeout_invalid_status_raises(monkeypatch: pytest.MonkeyPatch) -> None: form = _DummyForm( id="f", diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py index c3e6f5d76c3821..55dcbdb7a1102b 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py @@ -81,7 +81,7 @@ def __init__( if isinstance(self, TemplateTransformNode): kwargs.setdefault("jinja2_template_renderer", _TestJinja2Renderer()) - # Provide default ToolNode dependencies for ToolNode subclasses. + # Provide default tool_file_manager for ToolNode subclasses from graphon.nodes.tool import ToolNode as _ToolNode # local import to avoid cycles if isinstance(self, _ToolNode): diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py index fd6263be1920a3..a16a8b481a62a6 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py @@ -12,6 +12,7 @@ from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason +from graphon.file import File, FileTransferMethod, FileType from graphon.graph import Graph from graphon.graph_engine import GraphEngine, GraphEngineConfig from graphon.graph_engine.command_channels import InMemoryChannel @@ -24,8 +25,15 @@ 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 HumanInputNodeData, UserActionConfig -from graphon.nodes.human_input.enums import HumanInputFormStatus +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 @@ -52,6 +60,21 @@ def load(self) -> GraphRuntimeState: return GraphRuntimeState.from_snapshot(self._snapshot) +class _TestFileReferenceFactory: + def build_from_mapping(self, *, mapping: Mapping[str, Any]) -> File: + return File( + file_id=mapping.get("id"), + file_type=FileType(mapping["type"]), + transfer_method=FileTransferMethod(mapping["transfer_method"]), + remote_url=mapping.get("remote_url") or mapping.get("url"), + related_id=mapping.get("related_id") or mapping.get("upload_file_id"), + filename=mapping.get("filename"), + extension=mapping.get("extension"), + mime_type=mapping.get("mime_type"), + size=mapping.get("size", -1), + ) + + @dataclass class StaticForm(HumanInputFormEntity): form_id: str @@ -106,6 +129,9 @@ def __init__(self, forms_by_node_id: Mapping[str, HumanInputFormEntity]) -> None def get_form(self, node_id: str) -> HumanInputFormEntity | None: return self._forms_by_node_id.get(node_id) + def set_forms(self, forms_by_node_id: Mapping[str, HumanInputFormEntity]) -> None: + self._forms_by_node_id = dict(forms_by_node_id) + def create_form(self, params: FormCreateParams) -> HumanInputFormEntity: raise AssertionError("create_form should not be called in resume scenario") @@ -148,7 +174,14 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor human_data = HumanInputNodeData( title="Human Input", form_content="Human input required", - inputs=[], + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]), + ), + FileInputConfig(output_variable_name="attachment"), + FileListInputConfig(output_variable_name="attachments", number_limits=2), + ], user_actions=[UserActionConfig(id="approve", title="Approve")], ) @@ -177,8 +210,12 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor end_data = EndNodeData( title="End", outputs=[ - OutputVariableEntity(variable="res_a", value_selector=["human_a", "__action_id"]), - OutputVariableEntity(variable="res_b", value_selector=["human_b", "__action_id"]), + OutputVariableEntity(variable="res_a_action", value_selector=["human_a", "__action_id"]), + OutputVariableEntity(variable="res_a_decision", value_selector=["human_a", "decision"]), + OutputVariableEntity(variable="res_a_attachment", value_selector=["human_a", "attachment"]), + OutputVariableEntity(variable="res_b_action", value_selector=["human_b", "__action_id"]), + OutputVariableEntity(variable="res_b_decision", value_selector=["human_b", "decision"]), + OutputVariableEntity(variable="res_b_attachments", value_selector=["human_b", "attachments"]), ], desc=None, ) @@ -216,13 +253,13 @@ def _run_graph(graph: Graph, runtime_state: GraphRuntimeState) -> list[object]: return list(engine.run()) -def _form(submitted: bool, action_id: str | None) -> StaticForm: +def _form(submitted: bool, action_id: str | None, data: Mapping[str, Any] | None = None) -> StaticForm: return StaticForm( form_id="form", rendered="rendered", is_submitted=submitted, action_id=action_id, - data={}, + data=data, status_value=HumanInputFormStatus.SUBMITTED if submitted else HumanInputFormStatus.WAITING, ) @@ -246,7 +283,21 @@ def test_parallel_human_input_join_completes_after_second_resume() -> None: first_resume_state = pause_store.load() first_resume_repo = StaticRepo( { - "human_a": _form(submitted=True, action_id="approve"), + "human_a": _form( + submitted=True, + action_id="approve", + data={ + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/resume.pdf", + "filename": "resume.pdf", + "extension": ".pdf", + "mime_type": "application/pdf", + }, + }, + ), "human_b": _form(submitted=False, action_id=None), } ) @@ -256,19 +307,68 @@ def test_parallel_human_input_join_completes_after_second_resume() -> None: assert isinstance(first_resume_events[0], GraphRunStartedEvent) assert first_resume_events[0].reason is WorkflowStartReason.RESUMPTION assert isinstance(first_resume_events[-1], GraphRunPausedEvent) - pause_store.save(first_resume_state) - - second_resume_state = pause_store.load() - second_resume_repo = StaticRepo( + second_resume_state = first_resume_state + first_resume_repo.set_forms( { - "human_a": _form(submitted=True, action_id="approve"), - "human_b": _form(submitted=True, action_id="approve"), + "human_a": _form( + submitted=True, + action_id="approve", + data={ + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/resume.pdf", + "filename": "resume.pdf", + "extension": ".pdf", + "mime_type": "application/pdf", + }, + }, + ), + "human_b": _form( + submitted=True, + action_id="approve", + data={ + "decision": "reject", + "attachments": [ + { + "type": "image", + "transfer_method": "remote_url", + "remote_url": "https://example.com/a.png", + "filename": "a.png", + "extension": ".png", + "mime_type": "image/png", + }, + { + "type": "image", + "transfer_method": "remote_url", + "remote_url": "https://example.com/b.png", + "filename": "b.png", + "extension": ".png", + "mime_type": "image/png", + }, + ], + }, + ), } ) - second_resume_graph = _build_graph(second_resume_state, second_resume_repo) - second_resume_events = _run_graph(second_resume_graph, second_resume_state) + second_resume_events = _run_graph(first_resume_graph, second_resume_state) assert isinstance(second_resume_events[0], GraphRunStartedEvent) assert second_resume_events[0].reason is WorkflowStartReason.RESUMPTION assert isinstance(second_resume_events[-1], GraphRunSucceededEvent) assert any(isinstance(event, NodeRunSucceededEvent) and event.node_id == "end" for event in second_resume_events) + second_resume_outputs = second_resume_state.outputs + assert second_resume_outputs["res_a_action"] == "approve" + assert second_resume_outputs["res_a_decision"] == "approve" + assert isinstance(second_resume_outputs["res_a_attachment"], File) + res_a_attachment_in_second_outputs = second_resume_outputs["res_a_attachment"] + assert isinstance(res_a_attachment_in_second_outputs, File) + assert res_a_attachment_in_second_outputs.filename == "resume.pdf" + assert res_a_attachment_in_second_outputs.type == FileType.DOCUMENT + assert res_a_attachment_in_second_outputs.transfer_method == FileTransferMethod.REMOTE_URL + assert second_resume_outputs["res_b_action"] == "approve" + assert second_resume_outputs["res_b_decision"] == "reject" + assert isinstance(second_resume_outputs["res_b_attachments"], list) + assert [file.filename for file in second_resume_outputs["res_b_attachments"]] == ["a.png", "b.png"] + assert all(file.type == FileType.IMAGE for file in second_resume_outputs["res_b_attachments"]) diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index a5a8e877f28826..1d68610e7f4e2e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -33,11 +33,16 @@ from core.workflow.node_runtime import DifyFileReferenceFactory, 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 ( + FileInputConfig, + FileListInputConfig, HumanInputNodeData, ParagraphInputConfig, + SelectInputConfig, + StringListSource, StringSource, UserActionConfig, ) @@ -49,7 +54,9 @@ ValueSourceType, ) from graphon.nodes.human_input.human_input_node import HumanInputNode +from graphon.nodes.protocols import FileReferenceFactoryProtocol from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from libs.datetime_utils import naive_utc_now @@ -136,6 +143,23 @@ def set_submission(self, *, action_id: str, form_data: Mapping[str, Any] | None entity.status_value = HumanInputFormStatus.SUBMITTED +class _TestFileReferenceFactory(FileReferenceFactoryProtocol): + """Build graph-layer file objects without touching Dify persistence in unit tests.""" + + def build_from_mapping(self, *, mapping: Mapping[str, Any]) -> File: + return File( + file_id=mapping.get("id"), + file_type=FileType(mapping["type"]), + transfer_method=FileTransferMethod(mapping["transfer_method"]), + remote_url=mapping.get("remote_url") or mapping.get("url"), + related_id=mapping.get("related_id") or mapping.get("upload_file_id"), + filename=mapping.get("filename"), + extension=mapping.get("extension"), + mime_type=mapping.get("mime_type"), + size=mapping.get("size", -1), + ) + + def _build_human_input_node( *, node_id: str, @@ -198,12 +222,11 @@ def test_paragraph_input_with_constant_default(self): """Test paragraph input with constant default value.""" default = StringSource(type=ValueSourceType.CONSTANT, value="Enter your response here...") - form_input = ParagraphInputConfig( - type=FormInputType.PARAGRAPH, output_variable_name="user_input", default=default - ) + form_input = ParagraphInputConfig(output_variable_name="user_input", default=default) assert form_input.type == FormInputType.PARAGRAPH assert form_input.output_variable_name == "user_input" + assert form_input.default is not None assert form_input.default.type == ValueSourceType.CONSTANT assert form_input.default.value == "Enter your response here..." @@ -211,16 +234,15 @@ def test_paragraph_input_with_variable_default(self): """Test paragraph input with variable default value.""" default = StringSource(type=ValueSourceType.VARIABLE, selector=["node_123", "output_var"]) - form_input = ParagraphInputConfig( - type=FormInputType.PARAGRAPH, output_variable_name="user_input", default=default - ) + form_input = ParagraphInputConfig(output_variable_name="user_input", default=default) + assert form_input.default is not None assert form_input.default.type == ValueSourceType.VARIABLE assert form_input.default.selector == ["node_123", "output_var"] def test_form_input_without_default(self): """Test form input without default value.""" - form_input = ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="description") + form_input = ParagraphInputConfig(output_variable_name="description") assert form_input.type == FormInputType.PARAGRAPH assert form_input.output_variable_name == "description" @@ -279,7 +301,6 @@ def test_valid_node_data_creation(self): inputs = [ ParagraphInputConfig( - type=FormInputType.PARAGRAPH, output_variable_name="content", default=StringSource(type=ValueSourceType.CONSTANT, value="Enter content..."), ) @@ -343,8 +364,8 @@ def test_node_data_defaults(self): def test_duplicate_input_output_variable_name_raises_validation_error(self): """Duplicate form input output_variable_name should raise validation error.""" duplicate_inputs = [ - ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content"), - ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content"), + ParagraphInputConfig(output_variable_name="content"), + ParagraphInputConfig(output_variable_name="content"), ] with pytest.raises(ValidationError, match="duplicated output_variable_name 'content'"): @@ -464,12 +485,10 @@ def test_resolves_variable_defaults(self): form_content="Provide your name", inputs=[ ParagraphInputConfig( - type=FormInputType.PARAGRAPH, output_variable_name="user_name", default=StringSource(type=ValueSourceType.VARIABLE, selector=["start", "name"]), ), ParagraphInputConfig( - type=FormInputType.PARAGRAPH, output_variable_name="user_email", default=StringSource(type=ValueSourceType.CONSTANT, value="foo@example.com"), ), @@ -726,9 +745,11 @@ class TestValidation: def test_invalid_form_input_type(self): """Test validation with invalid form input type.""" with pytest.raises(ValidationError): - ParagraphInputConfig( - type="invalid-type", # Invalid type - output_variable_name="test", + ParagraphInputConfig.model_validate( + { + "type": "invalid-type", + "output_variable_name": "test", + } ) def test_invalid_button_style(self): @@ -782,11 +803,79 @@ def test_replaces_outputs_placeholders_after_submission(self): node_data = HumanInputNodeData( title="Human Input", form_content="Name: {{#$output.name#}}", + inputs=[ParagraphInputConfig(output_variable_name="name")], + user_actions=[UserActionConfig(id="approve", title="Approve")], + ) + config = {"id": "human", "data": node_data.model_dump()} + + form_repository = InMemoryHumanInputFormRepository() + runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) + runtime._build_form_repository = MagicMock(return_value=form_repository) # type: ignore[attr-defined] + node = _build_human_input_node( + node_id=config["id"], + node_data=config["data"], + graph_init_params=graph_init_params, + graph_runtime_state=runtime_state, + runtime=runtime, + ) + + pause_gen = node._run() + pause_event = next(pause_gen) + assert isinstance(pause_event, PauseRequestedEvent) + with pytest.raises(StopIteration): + next(pause_gen) + + form_repository.set_submission(action_id="approve", form_data={"name": "Alice"}) + + events = list(node._run()) + last_event = events[-1] + assert isinstance(last_event, StreamCompletedEvent) + node_run_result = last_event.node_run_result + assert node_run_result.outputs["name"] == StringSegment(value="Alice") + assert node_run_result.outputs["__action_id"] == StringSegment(value="approve") + assert node_run_result.outputs["__rendered_content"] == StringSegment(value="Name: Alice") + + def test_resume_restores_file_outputs_as_runtime_segments(self): + variable_pool = VariablePool.from_bootstrap( + system_variables=build_system_variables( + user_id="user", + app_id="app", + workflow_id="workflow", + workflow_execution_id="run", + ), + user_inputs={}, + conversation_variables=[], + ) + runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) + graph_init_params = GraphInitParams( + workflow_id="workflow", + graph_config={"nodes": [], "edges": []}, + run_context={ + DIFY_RUN_CONTEXT_KEY: { + "tenant_id": "tenant", + "app_id": "app", + "user_id": "user", + "user_from": "account", + "invoke_from": "debugger", + } + }, + call_depth=0, + ) + + node_data = HumanInputNodeData( + title="Human Input", + form_content=( + "Decision: {{#$output.decision#}}\n" + "Attachment: {{#$output.attachment#}}\n" + "Attachments: {{#$output.attachments#}}" + ), inputs=[ - ParagraphInputConfig( - type=FormInputType.PARAGRAPH, - output_variable_name="name", - ) + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type="constant", value=["approve", "reject"]), + ), + FileInputConfig(output_variable_name="attachment"), + FileListInputConfig(output_variable_name="attachments", number_limits=2), ], user_actions=[UserActionConfig(id="approve", title="Approve")], ) @@ -809,10 +898,48 @@ def test_replaces_outputs_placeholders_after_submission(self): with pytest.raises(StopIteration): next(pause_gen) - form_repository.set_submission(action_id="approve", form_data={"name": "Alice"}) + form_repository.set_submission( + action_id="approve", + form_data={ + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/resume.pdf", + "filename": "resume.pdf", + "extension": ".pdf", + "mime_type": "application/pdf", + }, + "attachments": [ + { + "type": "image", + "transfer_method": "remote_url", + "remote_url": "https://example.com/a.png", + "filename": "a.png", + "extension": ".png", + "mime_type": "image/png", + }, + { + "type": "image", + "transfer_method": "remote_url", + "remote_url": "https://example.com/b.png", + "filename": "b.png", + "extension": ".png", + "mime_type": "image/png", + }, + ], + }, + ) events = list(node._run()) last_event = events[-1] assert isinstance(last_event, StreamCompletedEvent) node_run_result = last_event.node_run_result - assert node_run_result.outputs["__rendered_content"].to_object() == "Name: Alice" + assert node_run_result.outputs["decision"] == StringSegment(value="approve") + assert node_run_result.outputs["__rendered_content"] == StringSegment( + value="Decision: approve\nAttachment: [file]\nAttachments: [2 files]" + ) + assert isinstance(node_run_result.outputs["attachment"], FileSegment) + assert node_run_result.outputs["attachment"].value.filename == "resume.pdf" + assert isinstance(node_run_result.outputs["attachments"], ArrayFileSegment) + assert [file.filename for file in node_run_result.outputs["attachments"].value] == ["a.png", "b.png"] diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index 40522a0d4f0c85..763e1eecfd71e3 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -1,20 +1,34 @@ import datetime +from collections.abc import Mapping 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 DifyFileReferenceFactory, 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 graphon.nodes.human_input.entities import HumanInputNodeData +from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + HumanInputNodeData, + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + UserActionConfig, +) from graphon.nodes.human_input.enums import HumanInputFormStatus from graphon.nodes.human_input.human_input_node import HumanInputNode +from graphon.nodes.protocols import FileReferenceFactoryProtocol from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment +from graphon.variables.types import SegmentType from libs.datetime_utils import naive_utc_now @@ -26,6 +40,21 @@ def get_form(self, *_args, **_kwargs): return self._form +class _TestFileReferenceFactory(FileReferenceFactoryProtocol): + def build_from_mapping(self, *, mapping: Mapping[str, Any]): + return File( + file_id=mapping.get("id"), + file_type=FileType(mapping["type"]), + transfer_method=FileTransferMethod(mapping["transfer_method"]), + remote_url=mapping.get("remote_url") or mapping.get("url"), + related_id=mapping.get("related_id") or mapping.get("upload_file_id"), + filename=mapping.get("filename"), + extension=mapping.get("extension"), + mime_type=mapping.get("mime_type"), + size=mapping.get("size", -1), + ) + + def _create_human_input_node( *, config: dict, @@ -49,7 +78,14 @@ def _create_human_input_node( ) -def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name#}}") -> HumanInputNode: +def _build_node( + form_content: str = ( + "Please enter your name:\n\n{{#$output.name#}}\n" + "Decision: {{#$output.decision#}}\n" + "Attachment: {{#$output.attachment#}}\n" + "Attachments: {{#$output.attachments#}}" + ), +) -> HumanInputNode: system_variables = default_system_variables() graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap( @@ -81,19 +117,15 @@ def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name# "title": "Human Input", "form_content": form_content, "inputs": [ - { - "type": "paragraph", - "output_variable_name": "name", - "default": {"type": "constant", "value": ""}, - } - ], - "user_actions": [ - { - "id": "Accept", - "title": "Approve", - "button_style": "default", - } + ParagraphInputConfig(output_variable_name="name").model_dump(mode="json"), + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type="constant", value=["approve", "reject"]), + ).model_dump(mode="json"), + FileInputConfig(output_variable_name="attachment").model_dump(mode="json"), + FileListInputConfig(output_variable_name="attachments", number_limits=2).model_dump(mode="json"), ], + "user_actions": [UserActionConfig(id="Accept", title="Approve").model_dump(mode="json")], }, } @@ -102,7 +134,28 @@ def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name# rendered_content=form_content, submitted=True, selected_action_id="Accept", - submitted_data={"name": "Alice"}, + submitted_data={ + "name": "Alice", + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/resume.pdf", + "filename": "resume.pdf", + "extension": ".pdf", + "mime_type": "application/pdf", + }, + "attachments": [ + { + "type": "image", + "transfer_method": "remote_url", + "remote_url": "https://example.com/a.png", + "filename": "a.png", + "extension": ".png", + "mime_type": "image/png", + } + ], + }, status=HumanInputFormStatus.SUBMITTED, expiration_time=naive_utc_now() + datetime.timedelta(days=1), ) @@ -147,20 +200,8 @@ def _build_timeout_node() -> HumanInputNode: "data": { "title": "Human Input", "form_content": "Please enter your name:\n\n{{#$output.name#}}", - "inputs": [ - { - "type": "paragraph", - "output_variable_name": "name", - "default": {"type": "constant", "value": ""}, - } - ], - "user_actions": [ - { - "id": "Accept", - "title": "Approve", - "button_style": "default", - } - ], + "inputs": [ParagraphInputConfig(output_variable_name="name").model_dump(mode="json")], + "user_actions": [UserActionConfig(id="Accept", title="Approve").model_dump(mode="json")], }, } @@ -193,9 +234,22 @@ def test_human_input_node_emits_form_filled_event_before_succeeded(): filled_event = events[1] assert filled_event.node_title == "Human Input" - assert filled_event.rendered_content.endswith("Alice") + assert filled_event.rendered_content == ( + "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 def test_human_input_node_emits_timeout_event_before_succeeded(): diff --git a/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py b/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py new file mode 100644 index 00000000000000..cc83a17dfc3fca --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py @@ -0,0 +1,338 @@ +import json +from typing import Any + +from pydantic import TypeAdapter + +from core.app.entities.task_entities import HumanInputRequiredResponse +from core.entities.execution_extra_content import ( + HumanInputContent, + HumanInputFormDefinition, +) +from graphon.entities.pause_reason import HumanInputRequired +from graphon.nodes.human_input.entities import ( + FormDefinition, + FormInputConfig, + HumanInputNodeData, +) +from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType + + +def _legacy_form_input_payloads() -> list[dict[str, Any]]: + return [ + { + "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"], + }, + }, + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url"], + }, + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 3, + }, + { + "type": "paragraph", + "output_variable_name": "summary", + "default": None, + }, + ] + + +def _legacy_user_action_payloads() -> list[dict[str, Any]]: + return [ + { + "id": "approve", + "title": "Approve", + "button_style": "primary", + }, + { + "id": "reject", + "title": "Reject", + "button_style": "default", + }, + ] + + +def _validate_legacy_json(model_class: type, payload: dict[str, Any]) -> Any: + adapter = TypeAdapter(model_class) + return adapter.validate_json(json.dumps(payload)) + + +def test_form_input_accepts_current_serialized_payload() -> None: + payload = { + "type": "paragraph", + "output_variable_name": "name", + "default": { + "type": "constant", + "selector": [], + "value": "Alice", + }, + } + + restored = _validate_legacy_json(FormInputConfig, payload) + assert restored.default is not None + assert restored.default.type == ValueSourceType.CONSTANT + + +def test_human_input_node_data_accepts_current_serialized_payload() -> None: + payload = { + "type": "human-input", + "title": "Human Input", + "form_content": "Hello {{#$output.name#}}", + "inputs": _legacy_form_input_payloads(), + "user_actions": _legacy_user_action_payloads(), + "timeout": 2, + "timeout_unit": "day", + } + + restored = _validate_legacy_json(HumanInputNodeData, payload) + assert restored.inputs[0].output_variable_name == "name" + assert restored.timeout_unit == TimeoutUnit.DAY + + +def test_form_definition_accepts_current_serialized_payload() -> None: + payload = { + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "user_actions": _legacy_user_action_payloads(), + "rendered_content": "Please confirm", + "expiration_time": "2024-01-01T00:00:00Z", + "default_values": {"name": "Alice"}, + "node_title": "Human Input", + "display_in_ui": True, + } + + restored = _validate_legacy_json(FormDefinition, payload) + assert restored.inputs[2].output_variable_name == "attachment" + assert restored.user_actions[0].id == "approve" + assert restored.user_actions[0].button_style == ButtonStyle.PRIMARY + + +def test_human_input_required_pause_reason_accepts_current_serialized_payload() -> None: + payload = { + "TYPE": "human_input_required", + "form_id": "form-1", + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "actions": _legacy_user_action_payloads(), + "node_id": "node-1", + "node_title": "Human Input", + "resolved_default_values": {"name": "Alice"}, + } + + restored = _validate_legacy_json(HumanInputRequired, payload) + assert restored.inputs[1].output_variable_name == "decision" + assert restored.actions[0].id == "approve" + assert restored.TYPE == "human_input_required" + + +def test_human_input_form_definition_accepts_current_serialized_payload() -> None: + payload = { + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "actions": _legacy_user_action_payloads(), + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {"name": "Alice"}, + "expiration_time": 1700000000, + } + + restored = _validate_legacy_json(HumanInputFormDefinition, payload) + assert restored.inputs[3].output_variable_name == "attachments" + assert restored.actions[0].id == "approve" + + +def test_human_input_content_accepts_current_serialized_payload() -> None: + payload = { + "workflow_run_id": "run-1", + "submitted": True, + "form_definition": { + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "actions": _legacy_user_action_payloads(), + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {"name": "Alice"}, + "expiration_time": 1700000000, + }, + "form_submission_data": { + "node_id": "node-1", + "node_title": "Human Input", + "rendered_content": "Please confirm", + "action_id": "approve", + "action_text": "Approve", + }, + "type": "human_input", + } + + restored = _validate_legacy_json(HumanInputContent, payload) + assert restored.form_definition is not None + assert restored.form_definition.inputs[0].output_variable_name == "name" + + +def test_human_input_content_accepts_current_serialized_payload_with_form_data() -> None: + payload = { + "workflow_run_id": "run-1", + "submitted": True, + "form_definition": { + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "Please confirm", + "inputs": [ + { + "type": "select", + "output_variable_name": "decision", + "option_source": {"type": "constant", "selector": [], "value": ["approve", "reject"]}, + }, + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url"], + }, + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 3, + }, + ], + "actions": _legacy_user_action_payloads(), + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {"decision": "approve"}, + "expiration_time": 1700000000, + }, + "form_submission_data": { + "node_id": "node-1", + "node_title": "Human Input", + "rendered_content": "Please confirm", + "action_id": "approve", + "action_text": "Approve", + "submitted_data": { + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/file.txt", + "filename": "file.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + "attachments": [ + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/first.txt", + "filename": "first.txt", + "extension": ".txt", + "mime_type": "text/plain", + } + ], + }, + }, + "type": "human_input", + } + + restored = HumanInputContent.model_validate_json(json.dumps(payload)) + assert restored.form_submission_data is not None + assert restored.form_submission_data.submitted_data == payload["form_submission_data"]["submitted_data"] + + +def test_human_input_content_accepts_legacy_serialized_payload_with_form_data() -> None: + payload = { + "workflow_run_id": "run-1", + "submitted": True, + "form_definition": { + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "actions": _legacy_user_action_payloads(), + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {"decision": "approve"}, + "expiration_time": 1700000000, + }, + "form_submission_data": { + "node_id": "node-1", + "node_title": "Human Input", + "rendered_content": "Please confirm", + "action_id": "approve", + "action_text": "Approve", + "form_data": { + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/file.txt", + "filename": "file.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + }, + }, + "type": "human_input", + } + + restored = HumanInputContent.model_validate_json(json.dumps(payload)) + assert restored.form_submission_data is not None + assert restored.form_submission_data.submitted_data is None + + +def test_human_input_required_response_accepts_current_serialized_payload() -> None: + payload = { + "event": "human_input_required", + "task_id": "task-1", + "workflow_run_id": "run-1", + "data": { + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "actions": _legacy_user_action_payloads(), + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {"name": "Alice"}, + "expiration_time": 1700000000, + }, + } + + restored = _validate_legacy_json(HumanInputRequiredResponse, payload) + assert restored.data.inputs[1].output_variable_name == "decision" + assert restored.data.actions[0].id == "approve" + assert restored.event == "human_input_required" diff --git a/api/tests/unit_tests/core/workflow/test_human_input_policy.py b/api/tests/unit_tests/core/workflow/test_human_input_policy.py index e6d0366af5377a..651b69216ae9f7 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_policy.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_policy.py @@ -1,8 +1,14 @@ +import pytest + from core.workflow.human_input_policy import ( HumanInputSurface, get_preferred_form_token, 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 graphon.runtime import VariablePool from models.human_input import RecipientType @@ -48,3 +54,40 @@ def test_preferred_form_token_uses_shared_priority_order() -> None: ] assert get_preferred_form_token(recipients) == "backstage-token" + + +def test_resolve_variable_select_input_options_uses_runtime_values() -> None: + variable_pool = VariablePool() + variable_pool.add(("start", "options"), ["approve", "reject"]) + inputs: list[SelectInputConfig] = [ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ] + + resolved = resolve_variable_select_input_options(inputs, variable_pool=variable_pool) + assert isinstance(resolved[0], SelectInputConfig) + assert resolved[0].option_source.value == ["approve", "reject"] + + +def test_resolve_variable_select_input_options_keeps_original_when_value_not_string_list() -> None: + variable_pool = VariablePool() + variable_pool.add(("start", "options"), [1, 2, 3]) + inputs = [ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ] + + with pytest.raises(TypeError): + resolve_variable_select_input_options(inputs, variable_pool=variable_pool) diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index 62e1a50291af9d..1c59440ee0b003 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -589,6 +589,7 @@ def test_creates_specialized_nodes(self, monkeypatch: pytest.MonkeyPatch, factor 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() elif constructor_name == "ToolNode": assert kwargs["tool_file_manager"] is sentinel.tool_file_manager @@ -598,6 +599,50 @@ def test_creates_specialized_nodes(self, monkeypatch: pytest.MonkeyPatch, factor assert kwargs["unstructured_api_config"] is sentinel.unstructured_api_config assert kwargs["http_client"] is sentinel.http_client + def test_human_input_node_receives_runtime_repository_and_file_reference_factory( + 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 + monkeypatch.setattr( + factory, + "_resolve_node_class", + MagicMock(return_value=constructor), + ) + + result = factory.create_node({"id": "human-node", "data": {"type": BuiltinNodeTypes.HUMAN_INPUT}}) + + 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() + + def test_tool_node_receives_tool_file_manager(self, monkeypatch: pytest.MonkeyPatch, factory) -> None: + created_node = object() + constructor = _node_constructor(return_value=created_node) + factory._bound_tool_file_manager_factory = MagicMock(return_value=sentinel.tool_file_manager) + monkeypatch.setattr( + factory, + "_resolve_node_class", + MagicMock(return_value=constructor), + ) + + result = factory.create_node({"id": "tool-node", "data": {"type": BuiltinNodeTypes.TOOL}}) + + assert result is created_node + kwargs = constructor.call_args.kwargs + assert kwargs["tool_file_manager"] is sentinel.tool_file_manager + assert kwargs["runtime"] is sentinel.tool_runtime + assert "tool_file_manager_factory" not in kwargs + factory._bound_tool_file_manager_factory.assert_called_once_with() + def test_build_llm_compatible_node_init_kwargs_preserves_structured_output_switch(self, factory): node_data = LLMNodeData.model_validate( { diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index 5e83863dc20f7a..244e22a8674627 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -29,11 +29,12 @@ build_dify_llm_file_saver, resolve_dify_run_context, ) -from graphon.file import FileTransferMethod, FileType +from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType -from graphon.nodes.human_input.entities import HumanInputNodeData +from graphon.nodes.human_input.entities import FileInputConfig, FileListInputConfig, HumanInputNodeData from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType +from graphon.variables.segments import ArrayFileSegment, FileSegment from tests.workflow_test_utils import build_test_run_context @@ -621,6 +622,70 @@ def test_dify_human_input_runtime_preserves_webapp_delivery_for_web_invocations( assert params.delivery_methods[1].config.recipients.include_bound_group is True +def test_dify_human_input_runtime_restore_submitted_data_rehydrates_files() -> None: + runtime = DifyHumanInputNodeRuntime(_build_run_context()) + file_value = File( + file_id="file-1", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="upload-1", + filename="resume.pdf", + extension=".pdf", + mime_type="application/pdf", + size=128, + ) + file_list_value = [ + File( + file_id="file-2", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="upload-2", + filename="first.pdf", + extension=".pdf", + mime_type="application/pdf", + size=64, + ), + File( + file_id="file-3", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.com/second.pdf", + filename="second.pdf", + extension=".pdf", + mime_type="application/pdf", + size=96, + ), + ] + runtime._file_reference_factory.build_from_mapping = MagicMock(side_effect=[file_value, *file_list_value]) # type: ignore[method-assign] + node_data = HumanInputNodeData( + title="Human Input", + inputs=[ + FileInputConfig(output_variable_name="attachment"), + FileListInputConfig(output_variable_name="attachments", number_limits=2), + ], + ) + + restored = runtime.restore_submitted_data( + node_data=node_data, + submitted_data={ + "attachment": {"upload_file_id": "upload-1", "type": "document", "transfer_method": "local_file"}, + "attachments": [ + {"upload_file_id": "upload-2", "type": "document", "transfer_method": "local_file"}, + { + "url": "https://example.com/second.pdf", + "type": "document", + "transfer_method": "remote_url", + }, + ], + }, + ) + + assert restored["attachment"] is file_value + assert restored["attachments"] == file_list_value + assert isinstance(FileSegment(value=restored["attachment"]), FileSegment) + assert isinstance(ArrayFileSegment(value=restored["attachments"]), ArrayFileSegment) + + def test_build_dify_llm_file_saver_wires_runtime_adapters(monkeypatch: pytest.MonkeyPatch) -> None: file_saver_cls = MagicMock(return_value=sentinel.file_saver) monkeypatch.setattr("graphon.nodes.llm.file_saver.FileSaverImpl", file_saver_cls) diff --git a/api/tests/unit_tests/factories/test_build_from_mapping.py b/api/tests/unit_tests/factories/test_build_from_mapping.py index ffb151fbf419c7..4d87285f86c917 100644 --- a/api/tests/unit_tests/factories/test_build_from_mapping.py +++ b/api/tests/unit_tests/factories/test_build_from_mapping.py @@ -421,3 +421,29 @@ def test_disallowed_extensions(mock_upload_file): with pytest.raises(ValueError, match="File validation failed"): build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, config=restricted_config) + + +def test_custom_file_type_uses_extension_validation_under_strict_mode(mock_upload_file): + """Custom form uploads are classified by the configured extension list.""" + mock_upload_file.return_value.extension = "txt" + mock_upload_file.return_value.name = "notes.txt" + mock_upload_file.return_value.mime_type = "text/plain" + + custom_config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[".txt"], + ) + mapping = { + "transfer_method": "local_file", + "upload_file_id": TEST_UPLOAD_FILE_ID, + "type": "custom", + } + + file = build_from_mapping( + mapping=mapping, + tenant_id=TEST_TENANT_ID, + config=custom_config, + strict_type_validation=True, + ) + + assert file.type == FileType.CUSTOM diff --git a/api/tests/unit_tests/libs/_human_input/support.py b/api/tests/unit_tests/libs/_human_input/support.py index 6616cec9b8e3b8..0f593507fdbb97 100644 --- a/api/tests/unit_tests/libs/_human_input/support.py +++ b/api/tests/unit_tests/libs/_human_input/support.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta from typing import Any -from graphon.nodes.human_input.entities import ParagraphInputConfig +from graphon.nodes.human_input.entities import FormInputConfig from graphon.nodes.human_input.enums import TimeoutUnit from libs.datetime_utils import naive_utc_now @@ -45,7 +45,7 @@ class HumanInputForm: tenant_id: str app_id: str | None form_content: str - inputs: list[ParagraphInputConfig] + inputs: list[FormInputConfig] user_actions: list[dict[str, Any]] timeout: int timeout_unit: TimeoutUnit @@ -88,7 +88,7 @@ def calculate_expiration(self) -> None: def to_response_dict(self, *, include_site_info: bool) -> dict[str, Any]: inputs_response = [ { - "type": form_input.type.name.lower().replace("_", "-"), + "type": form_input.type.value, "output_variable_name": form_input.output_variable_name, } for form_input in self.inputs diff --git a/api/tests/unit_tests/libs/_human_input/test_form_service.py b/api/tests/unit_tests/libs/_human_input/test_form_service.py index cb4c2715d0a8af..decd7c484baa96 100644 --- a/api/tests/unit_tests/libs/_human_input/test_form_service.py +++ b/api/tests/unit_tests/libs/_human_input/test_form_service.py @@ -11,7 +11,6 @@ UserActionConfig, ) from graphon.nodes.human_input.enums import ( - FormInputType, TimeoutUnit, ) from libs.datetime_utils import naive_utc_now @@ -50,7 +49,7 @@ def sample_form_data(self): "tenant_id": "tenant-abc", "app_id": "app-def", "form_content": "# Test Form\n\nInput: {{#$output.input#}}", - "inputs": [ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="input", default=None)], + "inputs": [ParagraphInputConfig(output_variable_name="input")], "user_actions": [UserActionConfig(id="submit", title="Submit")], "timeout": 1, "timeout_unit": TimeoutUnit.HOUR, @@ -304,9 +303,7 @@ def test_validate_submission_with_extra_inputs(self): "tenant_id": "tenant-abc", "app_id": "app-def", "form_content": "Test form", - "inputs": [ - ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="required_input", default=None) - ], + "inputs": [ParagraphInputConfig(output_variable_name="required_input")], "user_actions": [UserActionConfig(id="submit", title="Submit")], "timeout": 1, "timeout_unit": TimeoutUnit.HOUR, diff --git a/api/tests/unit_tests/libs/_human_input/test_models.py b/api/tests/unit_tests/libs/_human_input/test_models.py index 1413eed51fad98..f6e4c9ec181e49 100644 --- a/api/tests/unit_tests/libs/_human_input/test_models.py +++ b/api/tests/unit_tests/libs/_human_input/test_models.py @@ -11,7 +11,6 @@ UserActionConfig, ) from graphon.nodes.human_input.enums import ( - FormInputType, TimeoutUnit, ) from libs.datetime_utils import naive_utc_now @@ -32,7 +31,7 @@ def sample_form_data(self): "tenant_id": "tenant-abc", "app_id": "app-def", "form_content": "# Test Form\n\nInput: {{#$output.input#}}", - "inputs": [ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="input", default=None)], + "inputs": [ParagraphInputConfig(output_variable_name="input")], "user_actions": [UserActionConfig(id="submit", title="Submit")], "timeout": 2, "timeout_unit": TimeoutUnit.HOUR, diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py new file mode 100644 index 00000000000000..df6805bcdf1ef0 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json +from datetime import timedelta +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 libs.datetime_utils import naive_utc_now +from models.execution_extra_content import HumanInputContent as HumanInputContentModel +from models.human_input import HumanInputForm +from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository + + +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"} + form_definition = FormDefinition( + form_content="content", + inputs=[], + user_actions=[UserActionConfig(id="approve", title="Approve")], + rendered_content="Rendered Approve", + expiration_time=expiration_time, + node_title="Approval", + display_in_ui=True, + ) + form = HumanInputForm( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + form_definition=form_definition.model_dump_json(), + rendered_content="Rendered Approve", + expiration_time=expiration_time, + selected_action_id="approve", + submitted_data=json.dumps(stored_submission_data), + submitted_at=naive_utc_now(), + status=HumanInputFormStatus.SUBMITTED, + ) + form.id = "form-1" + model = HumanInputContentModel.new( + workflow_run_id="workflow-run-1", + form_id=form.id, + message_id="message-1", + ) + model.id = "content-1" + model.form = form + repository = SQLAlchemyExecutionExtraContentRepository(cast(sessionmaker[Session], object())) + + content = repository._map_human_input_content(model, {}) + + assert content is not None + assert content.form_submission_data is not None + assert content.form_submission_data.submitted_data == stored_submission_data + + +def test_map_human_input_content_keeps_waiting_form_without_selected_action() -> None: + expiration_time = naive_utc_now() + timedelta(days=1) + form_definition = FormDefinition( + form_content="content", + inputs=[], + user_actions=[UserActionConfig(id="approve", title="Approve")], + rendered_content="Rendered Approval", + expiration_time=expiration_time, + node_title="Approval", + display_in_ui=True, + default_values={"decision": "approve"}, + ) + form = HumanInputForm( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + form_definition=form_definition.model_dump_json(), + rendered_content="Rendered Approval", + expiration_time=expiration_time, + status=HumanInputFormStatus.WAITING, + ) + form.id = "form-1" + model = HumanInputContentModel.new( + workflow_run_id="workflow-run-1", + form_id=form.id, + message_id="message-1", + ) + model.id = "content-1" + model.form = form + repository = SQLAlchemyExecutionExtraContentRepository(cast(sessionmaker[Session], object())) + + content = repository._map_human_input_content(model, {}) + + assert content is not None + assert content.submitted is False + assert content.form_submission_data is None + assert content.form_definition is not None + assert content.form_definition.form_id == "form-1" + assert content.form_definition.node_id == "node-1" + assert content.form_definition.node_title == "Approval" + assert content.form_definition.form_content == "Rendered Approval" + assert content.form_definition.resolved_default_values == {"decision": "approve"} diff --git a/api/tests/unit_tests/services/test_human_input_file_upload_service.py b/api/tests/unit_tests/services/test_human_input_file_upload_service.py new file mode 100644 index 00000000000000..b6e429a46ae26a --- /dev/null +++ b/api/tests/unit_tests/services/test_human_input_file_upload_service.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +import models.account as account_module +import services.human_input_file_upload_service as service_module +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 +from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.human_input import ( + HumanInputForm, + HumanInputFormRecipient, + HumanInputFormUploadFile, + HumanInputFormUploadToken, +) +from models.model import App, AppMode, EndUser +from models.workflow import WorkflowRun, WorkflowType +from services.human_input_file_upload_service import HITL_UPLOAD_TOKEN_PREFIX, HumanInputFileUploadService +from services.human_input_service import FormSubmittedError + + +@pytest.fixture +def session_maker(monkeypatch: pytest.MonkeyPatch): + engine = create_engine("sqlite:///:memory:") + monkeypatch.setattr(account_module, "db", SimpleNamespace(engine=engine)) + Base.metadata.create_all( + engine, + tables=[ + Tenant.__table__, + Account.__table__, + TenantAccountJoin.__table__, + App.__table__, + EndUser.__table__, + WorkflowRun.__table__, + HumanInputForm.__table__, + HumanInputFormRecipient.__table__, + HumanInputFormUploadToken.__table__, + HumanInputFormUploadFile.__table__, + ], + ) + try: + yield sessionmaker(bind=engine, expire_on_commit=False) + finally: + Base.metadata.drop_all( + engine, + tables=[ + HumanInputFormUploadFile.__table__, + HumanInputFormUploadToken.__table__, + HumanInputFormRecipient.__table__, + HumanInputForm.__table__, + WorkflowRun.__table__, + EndUser.__table__, + App.__table__, + TenantAccountJoin.__table__, + Account.__table__, + Tenant.__table__, + ], + ) + engine.dispose() + + +def _create_waiting_form( + session_maker, + *, + created_by_role: CreatorUserRole = CreatorUserRole.ACCOUNT, + form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME, +) -> tuple[str, str, str]: + form_id = "00000000-0000-0000-0000-000000000001" + recipient_id = "00000000-0000-0000-0000-000000000002" + workflow_run_id = None + if form_kind == HumanInputFormKind.RUNTIME: + workflow_run_id = "00000000-0000-0000-0000-000000000012" + tenant_id = "00000000-0000-0000-0000-000000000010" + app_id = "00000000-0000-0000-0000-000000000011" + now = naive_utc_now() + created_by = ( + "00000000-0000-0000-0000-000000000020" + if created_by_role == CreatorUserRole.ACCOUNT + else "00000000-0000-0000-0000-000000000021" + ) + with session_maker.begin() as session: + tenant = Tenant(name="tenant-1") + tenant.id = tenant_id + session.add(tenant) + if created_by_role == CreatorUserRole.ACCOUNT: + account = Account(name="owner", email="owner@example.com") + account.id = created_by + session.add(account) + session.add( + TenantAccountJoin( + tenant_id=tenant_id, + account_id=created_by, + current=True, + ) + ) + app_creator = created_by + else: + end_user = EndUser( + tenant_id=tenant_id, + app_id=app_id, + type="web_app", + is_anonymous=False, + session_id="session-1", + external_user_id="external-1", + ) + end_user.id = created_by + session.add(end_user) + app_creator = "00000000-0000-0000-0000-000000000020" + account = Account(name="owner", email="owner@example.com") + account.id = app_creator + session.add(account) + session.add( + TenantAccountJoin( + tenant_id=tenant_id, + account_id=app_creator, + current=True, + ) + ) + app = App( + tenant_id=tenant_id, + name="app-1", + description="", + mode=AppMode.WORKFLOW, + icon_type="emoji", + icon="app", + icon_background="#ffffff", + enable_site=True, + enable_api=True, + created_by=app_creator, + updated_by=app_creator, + ) + app.id = app_id + session.add(app) + if workflow_run_id is not None: + workflow_run = WorkflowRun( + tenant_id=tenant_id, + app_id=app_id, + workflow_id="00000000-0000-0000-0000-000000000013", + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="1", + graph="{}", + inputs="{}", + status=WorkflowExecutionStatus.RUNNING, + created_by_role=created_by_role, + created_by=created_by, + created_at=now, + ) + workflow_run.id = workflow_run_id + session.add(workflow_run) + session.add( + HumanInputForm( + id=form_id, + tenant_id=tenant_id, + app_id=app_id, + workflow_run_id=workflow_run_id, + form_kind=form_kind, + node_id="node-1", + form_definition="{}", + rendered_content="content", + expiration_time=now + timedelta(hours=1), + created_at=now, + ) + ) + session.add( + HumanInputFormRecipient( + id=recipient_id, + form_id=form_id, + delivery_id="00000000-0000-0000-0000-000000000003", + recipient_type="standalone_web_app", + recipient_payload='{"TYPE": "standalone_web_app"}', + access_token="form-token-1", + ) + ) + return form_id, recipient_id, created_by + + +def test_issue_upload_token_persists_token_without_technical_end_user( + monkeypatch: pytest.MonkeyPatch, + session_maker, +) -> None: + form_id, recipient_id, _created_by = _create_waiting_form(session_maker) + monkeypatch.setattr(service_module.secrets, "token_urlsafe", lambda _bytes: "random-value") + + token = HumanInputFileUploadService(session_maker).issue_upload_token("form-token-1") + + assert token.upload_token == f"{HITL_UPLOAD_TOKEN_PREFIX}random-value" + with session_maker() as session: + token_model = session.scalar(select(HumanInputFormUploadToken)) + assert token_model is not None + assert token_model.form_id == form_id + assert token_model.recipient_id == recipient_id + assert token_model.token == token.upload_token + assert session.scalar(select(EndUser).where(EndUser.type == "human-input")) is None + + +def test_validate_upload_token_returns_account_owner_and_record_file_link(session_maker) -> None: + form_id, recipient_id, created_by = _create_waiting_form(session_maker, created_by_role=CreatorUserRole.ACCOUNT) + token = HumanInputFileUploadService(session_maker).issue_upload_token("form-token-1") + workflow_run_repository = MagicMock() + workflow_run_repository.get_workflow_run_by_id.return_value = SimpleNamespace( + tenant_id="00000000-0000-0000-0000-000000000010", + app_id="00000000-0000-0000-0000-000000000011", + created_by_role=CreatorUserRole.ACCOUNT, + created_by=created_by, + ) + + context = HumanInputFileUploadService( + session_maker, + workflow_run_repository=workflow_run_repository, + ).validate_upload_token(token.upload_token) + assert context.form_id == form_id + assert context.recipient_id == recipient_id + assert isinstance(context.owner, Account) + assert context.owner.id == created_by + assert context.owner.current_tenant_id == "00000000-0000-0000-0000-000000000010" + workflow_run_repository.get_workflow_run_by_id.assert_called_once_with( + tenant_id="00000000-0000-0000-0000-000000000010", + app_id="00000000-0000-0000-0000-000000000011", + run_id="00000000-0000-0000-0000-000000000012", + ) + + HumanInputFileUploadService(session_maker).record_upload_file( + context=context, + file_id="00000000-0000-0000-0000-000000000099", + ) + + with session_maker() as session: + link = session.scalar(select(HumanInputFormUploadFile)) + assert link is not None + assert link.tenant_id == context.tenant_id + assert link.app_id == context.app_id + assert link.form_id == form_id + assert link.upload_token_id == context.upload_token_id + + +def test_validate_upload_token_returns_end_user_owner(session_maker) -> None: + form_id, recipient_id, created_by = _create_waiting_form(session_maker, created_by_role=CreatorUserRole.END_USER) + token = HumanInputFileUploadService(session_maker).issue_upload_token("form-token-1") + workflow_run_repository = MagicMock() + workflow_run_repository.get_workflow_run_by_id.return_value = SimpleNamespace( + tenant_id="00000000-0000-0000-0000-000000000010", + app_id="00000000-0000-0000-0000-000000000011", + created_by_role=CreatorUserRole.END_USER, + created_by=created_by, + ) + + context = HumanInputFileUploadService( + session_maker, + workflow_run_repository=workflow_run_repository, + ).validate_upload_token(token.upload_token) + + assert context.form_id == form_id + assert context.recipient_id == recipient_id + assert isinstance(context.owner, EndUser) + assert context.owner.id == created_by + + +def test_validate_upload_token_allows_delivery_test_form(session_maker) -> None: + form_id, recipient_id, _created_by = _create_waiting_form( + session_maker, + form_kind=HumanInputFormKind.DELIVERY_TEST, + ) + token = HumanInputFileUploadService(session_maker).issue_upload_token("form-token-1") + + context = HumanInputFileUploadService(session_maker).validate_upload_token(token.upload_token) + + assert context.form_id == form_id + assert context.recipient_id == recipient_id + assert isinstance(context.owner, Account) + assert context.owner.id == "00000000-0000-0000-0000-000000000020" + assert context.owner.current_tenant_id == "00000000-0000-0000-0000-000000000010" + + +def test_validate_upload_token_rejects_submitted_form(session_maker) -> None: + form_id, _recipient_id, _created_by = _create_waiting_form(session_maker) + token = HumanInputFileUploadService(session_maker).issue_upload_token("form-token-1") + with session_maker.begin() as session: + form = session.get(HumanInputForm, form_id) + assert form is not None + form.status = HumanInputFormStatus.SUBMITTED + form.submitted_at = naive_utc_now() + + with pytest.raises(FormSubmittedError): + HumanInputFileUploadService(session_maker).validate_upload_token(token.upload_token) diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index b6370c03657fd1..5d9a16fb338ec2 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -6,18 +6,28 @@ from pytest_mock import MockerFixture import services.human_input_service as human_input_service_module +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.repositories.human_input_repository import ( HumanInputFormRecord, HumanInputFormSubmissionRepository, ) +from graphon.file import File, FileTransferMethod, FileType from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, FormDefinition, ParagraphInputConfig, + SelectInputConfig, + StringListSource, UserActionConfig, ) -from graphon.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus +from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now from models.human_input import RecipientType +from models.model import AppMode from services.human_input_service import ( Form, FormExpiredError, @@ -178,6 +188,70 @@ def test_get_form_definition_by_token_for_console_uses_repository(sample_form_re assert form.get_definition() == console_record.definition +def _build_resumption_context_state(*, options: list[str], workflow_run_id: str) -> bytes: + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant-id", + app_id="app-id", + app_mode=AppMode.WORKFLOW, + workflow_id="workflow-id", + ) + generate_entity = WorkflowAppGenerateEntity( + task_id="task-id", + app_config=app_config, + inputs={}, + files=[], + user_id="user-id", + stream=True, + invoke_from=InvokeFrom.EXPLORE, + call_depth=0, + workflow_execution_id=workflow_run_id, + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.variable_pool.add(("start", "options"), options) + context = WorkflowResumptionContext( + generate_entity=_WorkflowGenerateEntityWrapper(entity=generate_entity), + serialized_graph_runtime_state=runtime_state.dumps(), + ) + return context.dumps().encode() + + +def test_resolve_form_inputs_uses_runtime_select_options(sample_form_record, mock_session_factory, mocker): + session_factory, _ = mock_session_factory + configured_input = SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=["configured"], + ), + ) + record = dataclasses.replace( + sample_form_record, + definition=sample_form_record.definition.model_copy(update={"inputs": [configured_input]}), + ) + pause = MagicMock() + pause.resumed_at = None + pause.get_state.return_value = _build_resumption_context_state( + options=["approve", "reject"], + workflow_run_id=record.workflow_run_id or "", + ) + workflow_run_repo = MagicMock() + workflow_run_repo.get_workflow_pause.return_value = pause + mocker.patch( + "services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository", + return_value=workflow_run_repo, + ) + service = HumanInputService(session_factory) + + resolved_inputs = service.resolve_form_inputs(Form(record)) + + assert len(resolved_inputs) == 1 + resolved_input = resolved_inputs[0] + assert isinstance(resolved_input, SelectInputConfig) + assert resolved_input.option_source.value == ["approve", "reject"] + workflow_run_repo.get_workflow_pause.assert_called_once_with(record.workflow_run_id) + + def test_submit_form_by_token_calls_repository_and_enqueue( sample_form_record, mock_session_factory, mocker: MockerFixture ): @@ -280,7 +354,7 @@ def test_submit_form_by_token_missing_inputs(sample_form_record, mock_session_fa definition_with_input = FormDefinition( form_content="hello", - inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content")], + inputs=[ParagraphInputConfig(output_variable_name="content")], user_actions=sample_form_record.definition.user_actions, rendered_content="

hello

", expiration_time=sample_form_record.expiration_time, @@ -301,6 +375,157 @@ def test_submit_form_by_token_missing_inputs(sample_form_record, mock_session_fa repo.mark_submitted.assert_not_called() +def test_validate_human_input_submission_accepts_select_file_and_file_list(mock_session_factory): + session_factory, _ = mock_session_factory + service = HumanInputService(session_factory) + definition = FormDefinition.model_validate( + { + "form_content": "Pick one and upload files", + "inputs": [ + { + "type": "select", + "output_variable_name": "decision", + "option_source": { + "type": "constant", + "value": ["approve", "reject"], + }, + }, + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + }, + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 3, + }, + ], + "user_actions": [{"id": "submit", "title": "Submit"}], + "rendered_content": "

Pick one and upload files

", + "expiration_time": naive_utc_now() + timedelta(hours=1), + } + ) + + service.validate_human_input_submission( + form_definition=definition, + selected_action_id="submit", + form_data={ + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/file.txt", + "filename": "file.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + "attachments": [ + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/first.txt", + "filename": "first.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/second.txt", + "filename": "second.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + ], + }, + ) + + +@pytest.mark.parametrize( + ("input_definition", "submitted_value", "expected_message"), + [ + ( + { + "type": "select", + "output_variable_name": "decision", + "option_source": { + "type": "constant", + "value": ["approve", "reject"], + }, + }, + "unknown", + "decision", + ), + ( + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + }, + "not-a-file", + "attachment", + ), + ( + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 2, + }, + [ + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/ok.txt", + "filename": "ok.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + "not-a-file", + ], + "attachments", + ), + ], +) +def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads( + sample_form_record, + mock_session_factory, + input_definition, + submitted_value, + expected_message, +): + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition.model_validate( + { + "form_content": "Validate form data", + "inputs": [input_definition], + "user_actions": [{"id": "submit", "title": "Submit"}], + "rendered_content": "

Validate form data

", + "expiration_time": naive_utc_now() + timedelta(hours=1), + } + ) + repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) + service = HumanInputService(session_factory, form_repository=repo) + + with pytest.raises(InvalidFormDataError) as exc_info: + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={input_definition["output_variable_name"]: submitted_value}, + ) + + assert expected_message in str(exc_info.value) + repo.mark_submitted.assert_not_called() + + def test_form_properties(sample_form_record): form = Form(sample_form_record) assert form.id == "form-id" @@ -468,3 +693,203 @@ def test_is_globally_expired_zero_timeout(monkeypatch, sample_form_record, mock_ monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0) assert service._is_globally_expired(Form(sample_form_record)) is False + + +def test_submit_form_by_token_normalizes_select_and_files(sample_form_record, mock_session_factory, mocker) -> None: + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition( + form_content="hello", + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]), + ), + FileInputConfig(output_variable_name="attachment"), + FileListInputConfig(output_variable_name="attachments", number_limits=3), + ], + user_actions=[UserActionConfig(id="submit", title="Submit")], + rendered_content="

hello

", + expiration_time=sample_form_record.expiration_time, + ) + form_with_inputs = dataclasses.replace(sample_form_record, definition=definition) + repo.get_by_token.return_value = form_with_inputs + repo.mark_submitted.return_value = form_with_inputs + service = HumanInputService(session_factory, form_repository=repo) + + single_file = File( + file_id="file-1", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="upload-1", + filename="resume.pdf", + extension=".pdf", + mime_type="application/pdf", + size=128, + ) + list_files = [ + File( + file_id="file-2", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="upload-2", + filename="a.pdf", + extension=".pdf", + mime_type="application/pdf", + size=64, + ), + File( + file_id="file-3", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.com/b.pdf", + filename="b.pdf", + extension=".pdf", + mime_type="application/pdf", + size=96, + ), + ] + mocker.patch("services.human_input_service.build_from_mapping", return_value=single_file) + mocker.patch("services.human_input_service.build_from_mappings", return_value=list_files) + enqueue_spy = mocker.patch.object(service, "enqueue_resume") + + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={ + "decision": "approve", + "attachment": {"transfer_method": "local_file", "upload_file_id": "upload-1", "type": "document"}, + "attachments": [ + {"transfer_method": "local_file", "upload_file_id": "upload-2", "type": "document"}, + {"transfer_method": "remote_url", "url": "https://example.com/b.pdf", "type": "document"}, + ], + }, + ) + + submitted_data = repo.mark_submitted.call_args.kwargs["form_data"] + assert submitted_data["decision"] == "approve" + assert submitted_data["attachment"]["filename"] == "resume.pdf" + assert submitted_data["attachment"]["transfer_method"] == "local_file" + assert submitted_data["attachments"][0]["filename"] == "a.pdf" + assert submitted_data["attachments"][1]["filename"] == "b.pdf" + enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id) + + +def test_submit_form_by_token_invalid_select_value(sample_form_record, mock_session_factory) -> None: + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition( + form_content="hello", + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]), + ) + ], + user_actions=[UserActionConfig(id="submit", title="Submit")], + rendered_content="

hello

", + expiration_time=sample_form_record.expiration_time, + ) + repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) + service = HumanInputService(session_factory, form_repository=repo) + + with pytest.raises(InvalidFormDataError, match="Invalid value for select input 'decision'"): + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={"decision": "hold"}, + ) + + +def test_submit_form_by_token_invalid_file_list_item(sample_form_record, mock_session_factory) -> None: + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition( + form_content="hello", + inputs=[FileListInputConfig(output_variable_name="attachments", number_limits=2)], + user_actions=[UserActionConfig(id="submit", title="Submit")], + rendered_content="

hello

", + expiration_time=sample_form_record.expiration_time, + ) + repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) + service = HumanInputService(session_factory, form_repository=repo) + + with pytest.raises( + InvalidFormDataError, + match="Invalid value for file list input 'attachments': expected list of mappings", + ): + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={"attachments": ["not-a-file"]}, + ) + + +def test_submit_form_by_token_rejects_cross_tenant_file(sample_form_record, mock_session_factory, mocker) -> None: + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition( + form_content="hello", + inputs=[FileInputConfig(output_variable_name="attachment")], + user_actions=[UserActionConfig(id="submit", title="Submit")], + rendered_content="

hello

", + expiration_time=sample_form_record.expiration_time, + ) + repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) + service = HumanInputService(session_factory, form_repository=repo) + mocker.patch("services.human_input_service.build_from_mapping", side_effect=ValueError("Invalid upload file")) + + with pytest.raises(InvalidFormDataError, match="Invalid value for file input 'attachment': Invalid upload file"): + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={ + "attachment": { + "transfer_method": "local_file", + "upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e", + "type": "document", + } + }, + ) + + repo.mark_submitted.assert_not_called() + + +def test_submit_form_by_token_rejects_cross_tenant_file_list(sample_form_record, mock_session_factory, mocker) -> None: + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition( + form_content="hello", + inputs=[FileListInputConfig(output_variable_name="attachments", number_limits=2)], + user_actions=[UserActionConfig(id="submit", title="Submit")], + rendered_content="

hello

", + expiration_time=sample_form_record.expiration_time, + ) + repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) + service = HumanInputService(session_factory, form_repository=repo) + mocker.patch("services.human_input_service.build_from_mappings", side_effect=ValueError("Invalid upload file")) + + with pytest.raises( + InvalidFormDataError, + match="Invalid value for file list input 'attachments': Invalid upload file", + ): + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={ + "attachments": [ + { + "transfer_method": "local_file", + "upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e", + "type": "document", + } + ] + }, + ) + + repo.mark_submitted.assert_not_called() diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index f1053640948c39..c59262a8734a47 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -2654,6 +2654,7 @@ def test_submit_human_input_form_preview_success(self, service: WorkflowService) SimpleNamespace(id="submit", title="card_visa_enterprise_001"), ] mock_node.node_data.outputs_field_names.return_value = ["field1"] + mock_node.node_data.inputs = [] mock_node.render_form_content_before_submission.return_value = "Ticket: {{#$output.field1#}}" mock_node.render_form_content_with_outputs.return_value = "Ticket: val1" @@ -2663,7 +2664,10 @@ def test_submit_human_input_form_preview_success(self, service: WorkflowService) patch("models.workflow.Workflow.get_node_type_from_node_config", return_value=BuiltinNodeTypes.HUMAN_INPUT), patch.object(service, "_build_human_input_variable_pool"), patch("services.workflow_service.HumanInputNode", return_value=mock_node), - patch("services.workflow_service.validate_human_input_submission"), + patch( + "services.workflow_service.HumanInputService.validate_and_normalize_submission", + return_value={"field1": "val1"}, + ) as mock_validate, patch("services.workflow_service.Session"), patch("services.workflow_service.DraftVariableSaver") as mock_saver_cls, ): @@ -2671,6 +2675,7 @@ def test_submit_human_input_form_preview_success(self, service: WorkflowService) app_model=app_model, account=account, node_id="node-1", form_inputs={"field1": "val1"}, action="submit" ) assert result["__action_id"] == "submit" + mock_validate.assert_called_once() assert result["__action_value"] == "card_visa_enterprise_001" assert result["__rendered_content"] == "Ticket: val1" mock_saver_cls.return_value.save.assert_called_once() @@ -2686,7 +2691,7 @@ def test_test_human_input_delivery_success(self, service: WorkflowService) -> No patch.object(service, "_resolve_human_input_delivery_method") as mock_resolve, patch("services.workflow_service.apply_dify_debug_email_recipient"), patch.object(service, "_build_human_input_variable_pool"), - patch.object(service, "_build_human_input_node"), + patch.object(service, "_build_human_input_node_for_debugging"), patch.object(service, "_create_human_input_delivery_test_form", return_value=("form-1", [])), patch("services.workflow_service.HumanInputDeliveryTestService") as mock_test_srv, ): @@ -2814,8 +2819,8 @@ def test_rebuild_single_file_unreachable(self) -> None: with pytest.raises(Exception, match="unreachable"): _rebuild_single_file("tenant-1", {}, cast(Any, "invalid_type")) - def test_build_human_input_node(self, service: WorkflowService) -> None: - """Cover _build_human_input_node (lines 1065-1088).""" + 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" @@ -2835,10 +2840,11 @@ def test_build_human_input_node(self, service: WorkflowService) -> None: 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, ): mock_node_cls.validate_node_data.return_value = sentinel.node_data - node = service._build_human_input_node( + 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 @@ -2850,11 +2856,10 @@ def test_build_human_input_node(self, service: WorkflowService) -> None: 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_graph_init_context_cls.return_value.to_graph_init_params.return_value.run_context - ) + 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, diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py index 17e9a077d62e03..a997ea3583296d 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -18,6 +18,8 @@ from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from graphon.entities.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.model import AppMode @@ -106,7 +108,7 @@ def _build_snapshot(status: WorkflowNodeExecutionStatus) -> WorkflowNodeExecutio ) -def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: +def _build_resumption_context(task_id: str, *, select_options: list[str] | None = None) -> WorkflowResumptionContext: app_config = WorkflowUIBasedAppConfig( tenant_id="tenant-1", app_id="app-1", @@ -125,6 +127,8 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: workflow_execution_id="run-1", ) runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + if select_options is not None: + runtime_state.variable_pool.add(("start", "options"), select_options) runtime_state.register_paused_node("node-1") runtime_state.outputs = {"result": "value"} wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) @@ -787,6 +791,59 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) +def test_build_snapshot_events_resolves_pause_reason_select_options(monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED) + snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) + resumption_context = _build_resumption_context("task-ctx", select_options=["approve", "reject"]) + monkeypatch.setattr( + service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"} + ) + session_maker = _SessionMaker( + SimpleNamespace( + execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')], + ) + ) + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), + pause_reasons=[ + HumanInputRequired( + form_id="form-1", + form_content="content", + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ], + node_id="node-1", + node_title="Human Input", + ) + ], + ) + + events = _build_snapshot_events( + workflow_run=workflow_run, + node_snapshots=[snapshot], + task_id="task-ctx", + message_context=None, + pause_entity=pause_entity, + resumption_context=resumption_context, + session_maker=cast(sessionmaker[Session], session_maker), + ) + + human_input_event = events[-2] + assert human_input_event["data"]["inputs"][0]["option_source"]["value"] == ["approve", "reject"] + + pause_event = events[-1] + assert pause_event["data"]["reasons"][0]["inputs"][0]["option_source"]["value"] == ["approve", "reject"] + + def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_context( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py b/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py index 170bb24b8af01f..4b677bca62f111 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py @@ -76,11 +76,11 @@ def test_human_input_delivery_allows_disabled_method(monkeypatch: pytest.MonkeyP service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign] service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined] node_stub = MagicMock() - node_stub._render_form_content_before_submission.return_value = "rendered" - node_stub._resolve_default_values.return_value = {} - service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined] + node_stub.render_form_content_before_submission.return_value = "rendered" + node_stub.resolve_default_values.return_value = {} + service._build_human_input_node_for_debugging = MagicMock(return_value=node_stub) # type: ignore[attr-defined] service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined] - return_value=("form-1", {}) + return_value=("form-1", []) ) test_service_instance = MagicMock() @@ -112,11 +112,11 @@ def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.Mon service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign] service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined] node_stub = MagicMock() - node_stub._render_form_content_before_submission.return_value = "rendered" - node_stub._resolve_default_values.return_value = {} - service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined] + node_stub.render_form_content_before_submission.return_value = "rendered" + node_stub.resolve_default_values.return_value = {} + service._build_human_input_node_for_debugging = MagicMock(return_value=node_stub) # type: ignore[attr-defined] service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined] - return_value=("form-1", {}) + return_value=("form-1", []) ) test_service_instance = MagicMock() @@ -151,11 +151,11 @@ def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytes service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign] service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined] node_stub = MagicMock() - node_stub._render_form_content_before_submission.return_value = "rendered" - node_stub._resolve_default_values.return_value = {} - service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined] + node_stub.render_form_content_before_submission.return_value = "rendered" + node_stub.resolve_default_values.return_value = {} + service._build_human_input_node_for_debugging = MagicMock(return_value=node_stub) # type: ignore[attr-defined] service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined] - return_value=("form-1", {}) + return_value=("form-1", []) ) test_service_instance = MagicMock() diff --git a/docs/design/human-in-the-loop/hitl-form-file-upload-design.md b/docs/design/human-in-the-loop/hitl-form-file-upload-design.md new file mode 100644 index 00000000000000..86a81d70c95ff3 --- /dev/null +++ b/docs/design/human-in-the-loop/hitl-form-file-upload-design.md @@ -0,0 +1,184 @@ +# HITL Standalone Form File Upload Design + +## Context + +HITL standalone forms can be opened directly through a form link and do not require the +submitter to sign in through the Web App. After `file` and `file-list` inputs were introduced, +this standalone entry point also needed file upload support. + +This entry point has a different identity model from the existing upload paths: + +- Web App upload is backed by Web App authentication and an `EndUser` context. +- Service API upload is backed by API key authentication and the `user` parameter. +- HITL standalone form submission is link-based and anonymous from the product perspective. + The standalone submitter is not necessarily the workflow or chatflow initiator. + +The goal is therefore not to add another general-purpose upload channel. The goal is to +provide a constrained, short-lived upload capability that is scoped to one HITL form submission. + +## Goals + +- Support local file upload and remote URL upload from the HITL standalone page. +- Keep the standalone page independent from the Web App login flow. +- Avoid creating a technical HITL `EndUser`. +- Avoid changing the authentication model of existing Web App, Service API, or Console upload endpoints. +- Keep upload request parameters aligned with the equivalent Web App upload endpoints where possible. +- Invalidate upload capability once the form is submitted, expired, or timed out. +- Store uploaded files in a way that remains compatible with the existing workflow resume and file access model. + +## Decision + +HITL standalone upload uses a dedicated upload token that is bound to a form +recipient. The token authorizes file upload only while the related form is still valid. + +Files uploaded through the HITL standalone page are recorded under the workflow or +chatflow initiator, not under the anonymous standalone submitter and not under a technical HITL `EndUser`. + +This keeps workflow resume aligned with the existing execution model: one initiator owns the +workflow run context, and file restoration continues to resolve files through that initiator's +access scope. HITL-specific form/token/file relationships remain available for audit and tracing, +but they do not become the source of truth for file access control. + +## API Shape + +HITL standalone upload has three endpoint categories: + +| Purpose | HITL endpoint | Aligned Web App endpoint | +| --- | --- | --- | +| Issue upload token | `POST /api/form/human_input/{form_token}/upload-token` | No direct equivalent | +| Upload local file | `POST /api/form/human_input/files/upload` | `POST /api/files/upload` | +| Upload remote file | `POST /api/form/human_input/files/remote-upload` | `POST /api/remote-files/upload` | + +Local upload follows the Web App `POST /api/files/upload` parameter shape: + +- `multipart/form-data` +- Required `file` + +Remote upload follows the Web App `POST /api/remote-files/upload` parameter shape: + +- `application/json` +- Required `url` + +HITL upload endpoints do not accept the Service API `user` parameter. That parameter +belongs to the Service API `EndUser` mapping model and does not represent the anonymous +standalone form submitter. + +## Upload Token + +The upload token is issued through the form token: + +```http +POST /api/form/human_input/{form_token}/upload-token +``` + +Upload requests carry the token through the `Authorization` header: + +```http +Authorization: bearer hitl_upload_{random_value} +``` + +The `hitl_upload_` prefix only distinguishes this credential from other bearer token types. +Security comes from the high-entropy random value, server-side hash storage, and server-side state validation. + +The token is bound to at least: + +- The HITL form. +- The form recipient. +- The tenant. +- The app. + +The token must satisfy these rules: + +- It cannot outlive the form expiration. +- It cannot be used after the form is submitted, expired, or timed out. +- It is validated through the HITL upload path, not through the existing app token validation chain. + +## Why Authorization Header + +Putting `upload_token` in the request body would avoid additional CORS header configuration, but it has a bad failure mode for file upload. The server often needs to parse the multipart body before it can read a body token, so invalid requests can still consume upload parsing, temporary file, memory, or disk resources. + +Using `Authorization: bearer hitl_upload_{random_value}` keeps authentication before expensive business processing: + +- Invalid local upload requests can be rejected before reading the multipart body. +- Invalid remote upload requests can be rejected before any outbound network access. +- Bearer credential semantics are explicit and do not mix authentication with business fields. +- The token is not exposed through query strings, access logs, referrers, or browser history. + +The tradeoff is that cross-origin deployments must allow the `Authorization` header and accept browser preflight requests. This is a reasonable configuration cost for an earlier and clearer authentication boundary. + +## File Ownership + +The standalone submitter is not a reliable product identity. Assigning files to a technical `EndUser` would also conflict with workflow resume: existing file restoration expects files to be readable through the workflow or chatflow initiator's scope. + +The selected model is: + +- If the original run was started by an `Account`, standalone HITL uploads are stored under that `Account`. +- If the original run was started by an `EndUser`, standalone HITL uploads are stored under that `EndUser`. + +This means `UploadFile.created_by_role` and `UploadFile.created_by` continue to be the source of truth for file access control. HITL association records provide auditability but do not grant file access by themselves. + +## Persistence And Audit + +The HITL upload model has two responsibilities: + +- Upload tokens authorize a form recipient to upload files while the form remains valid. +- Upload-file association records trace which files were uploaded through which HITL upload token. + +These records are intentionally not tied to an `EndUser`. Their purpose is to preserve the HITL form/token/file relationship for audit and cleanup, not to define a separate file owner identity. + +## Local Upload Boundary + +Local upload should reuse the existing file upload semantics as much as possible: + +- Request parameters stay aligned with Web App local upload. +- Existing file size checks, extension restrictions, and document-type handling remain applicable. +- Response shape stays aligned with the existing file upload response. +- Token validation happens before reading the upload body. + +## Remote Upload Boundary + +Remote upload should reuse the existing remote upload semantics as much as possible: + +- Request parameters stay aligned with Web App remote upload. +- Token validation happens before outbound network access. +- Remote fetching continues to go through the existing SSRF-safe path. +- Remote filename, extension, MIME type, and file size inference stay aligned with existing behavior. +- Response shape stays aligned with the existing remote upload response. + +## Alternatives Considered + +### Unauthenticated Standalone Upload Endpoint + +This is the simplest implementation option and does not require Web App login, `EndUser`, or a new token model. It was not selected because it exposes a public file upload surface that can be abused in SaaS or internet-facing deployments. Adding authentication later would also change the endpoint contract after clients have integrated with it. + +### Reuse Web App Login And Upload + +This would maximize reuse of existing Web App upload behavior, but it would bind HITL standalone forms to Web App login, app code, Web App enablement, and enterprise SSO semantics. That coupling is undesirable because HITL forms can be reached from independent channels such as email. It also makes product behavior unclear when the Web App is disabled or the app code is reset. + +### Create `EndUser` From Form Token + +This would satisfy existing upload paths that require an `EndUser` context and would allow form state to limit upload capability. It was not selected because the created identity would be technical rather than a real submitter identity. It would also mix HITL standalone form behavior into the broader `EndUser` model already used by Web App, Service API, triggers, MCP, and other entry points. + +More importantly, files owned by this technical `EndUser` would not naturally be readable through the workflow initiator scope during workflow resume. + +### Technical `EndUser` With File Access Exception + +This would keep the technical `EndUser` as the file owner and add an access-control exception so the workflow initiator can read files uploaded through the same HITL form. It solves the immediate resume problem, but it pushes a HITL-specific rule into the general file access layer. Over time, that makes permission reasoning harder and increases the chance of accidental access expansion. + +Assigning files directly to the workflow or chatflow initiator avoids that bypass and keeps file access governed by the existing owner model. + +## Design Constraints + +- HITL standalone upload does not reuse the Web App login flow. +- HITL standalone upload does not create a technical HITL `EndUser`. +- Upload token validity is controlled by form state. +- File access control continues to use the `UploadFile` owner as the source of truth. +- HITL association records provide audit and traceability only. +- Workflow resume continues to restore submitted file values through the existing file restoration path. + +## Future Considerations + +- If endpoint parameters change, compare them with the corresponding Web App upload endpoint to avoid unnecessary drift. +- If file ownership changes, verify the workflow resume path and the file access model together. +- If HITL forms support multiple submissions or reopening, token invalidation semantics must be redefined. +- If remote upload policy expands, prefer extending the existing remote upload and SSRF-safe behavior instead of creating a HITL-only network path. diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 800bbc746bc688..d026ffe6bd7d40 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -137,11 +137,6 @@ "count": 1 } }, - "web/app/(humanInputLayout)/form/[token]/form.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/(shareLayout)/components/splash.tsx": { "react/set-state-in-effect": { "count": 1 @@ -697,16 +692,6 @@ "count": 1 } }, - "web/app/components/base/chat/chat/answer/human-input-content/utils.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/base/chat/chat/answer/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/chat/chat/answer/workflow-process.tsx": { "react/set-state-in-effect": { "count": 1 @@ -740,7 +725,7 @@ }, "web/app/components/base/chat/chat/index.tsx": { "ts/no-explicit-any": { - "count": 3 + "count": 2 }, "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -758,7 +743,7 @@ }, "web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx": { "ts/no-explicit-any": { - "count": 7 + "count": 6 } }, "web/app/components/base/chat/embedded-chatbot/context.ts": { @@ -881,9 +866,6 @@ "web/app/components/base/file-uploader/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 } }, "web/app/components/base/file-uploader/index.ts": { @@ -1455,11 +1437,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/index.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": { "no-restricted-imports": { "count": 1 @@ -1507,16 +1484,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx": { - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, - "web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -3575,27 +3542,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx": { - "react/unsupported-syntax": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/form-content.tsx": { - "react/no-nested-component-definitions": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, - "web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx": { - "react-refresh/only-export-components": { - "count": 2 - } - }, "web/app/components/workflow/nodes/human-input/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -4120,7 +4066,7 @@ }, "web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx": { "ts/no-explicit-any": { - "count": 6 + "count": 5 } }, "web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx": { @@ -4130,7 +4076,7 @@ }, "web/app/components/workflow/panel/debug-and-preview/hooks.ts": { "ts/no-explicit-any": { - "count": 12 + "count": 11 } }, "web/app/components/workflow/panel/env-panel/variable-modal.tsx": { @@ -4153,7 +4099,7 @@ }, "web/app/components/workflow/panel/workflow-preview.tsx": { "ts/no-explicit-any": { - "count": 2 + "count": 1 } }, "web/app/components/workflow/run/agent-log/index.tsx": { @@ -4658,9 +4604,6 @@ } }, "web/service/fetch.ts": { - "regexp/no-unused-capturing-group": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 57ccc78cce0d59..a983178e4d8479 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -8,14 +8,14 @@ Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`. -Are we OpenAPI ready? **No.** Current generated API contracts are **36.3% ready**. +Are we OpenAPI ready? **No.** Current generated API contracts are **36.2% ready**. | Surface | Ready | Not ready | Total | Ready % | | --------- | ------: | --------: | ------: | --------: | | console | 205 | 365 | 570 | 36.0% | | service | 28 | 60 | 88 | 31.8% | -| web | 21 | 20 | 41 | 51.2% | -| **total** | **254** | **445** | **699** | **36.3%** | +| web | 21 | 23 | 44 | 47.7% | +| **total** | **254** | **448** | **702** | **36.2%** | Readiness here means the generated contract operation is not marked with: diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 69435e357edd76..10da620f4e89af 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -1326,6 +1326,9 @@ export type HumanInputFormSubmissionData = { node_id: string node_title: string rendered_content: string + submitted_data?: { + [key: string]: JsonValue2 + } | null } export type ExecutionContentType = 'human_input' @@ -1360,6 +1363,8 @@ export type UserActionConfig = { export type FormInputConfig = unknown +export type JsonValue2 = unknown + export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' export type ParagraphInputConfig = { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 152296eec1c518..dbe4825fffa20a 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -1537,17 +1537,6 @@ export const zConversationPagination = z.object({ total: z.int(), }) -/** - * HumanInputFormSubmissionData - */ -export const zHumanInputFormSubmissionData = z.object({ - action_id: z.string(), - action_text: z.string(), - node_id: z.string(), - node_title: z.string(), - rendered_content: z.string(), -}) - /** * ExecutionContentType */ @@ -1631,6 +1620,20 @@ export const zWorkflowArchivedLogPaginationResponse = z.object({ export const zFormInputConfig = z.unknown() +export const zJsonValue2 = z.unknown() + +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue2).nullish(), +}) + /** * ButtonStyle * diff --git a/packages/contracts/generated/api/readiness.json b/packages/contracts/generated/api/readiness.json index d0c14d3df2f3c2..636f13192c1f60 100644 --- a/packages/contracts/generated/api/readiness.json +++ b/packages/contracts/generated/api/readiness.json @@ -9,8 +9,8 @@ "total": 88 }, "web": { - "notReady": 20, - "total": 41 + "notReady": 23, + "total": 44 } }, "warning": "Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate." diff --git a/packages/contracts/generated/api/web/orpc.gen.ts b/packages/contracts/generated/api/web/orpc.gen.ts index 4e7c949bdb777f..a314da03313bea 100644 --- a/packages/contracts/generated/api/web/orpc.gen.ts +++ b/packages/contracts/generated/api/web/orpc.gen.ts @@ -64,6 +64,10 @@ import { zPostForgotPasswordValidityResponse, zPostFormHumanInputByFormTokenPath, zPostFormHumanInputByFormTokenResponse, + zPostFormHumanInputByFormTokenUploadTokenPath, + zPostFormHumanInputByFormTokenUploadTokenResponse, + zPostFormHumanInputFilesRemoteUploadResponse, + zPostFormHumanInputFilesUploadResponse, zPostLoginBody, zPostLoginResponse, zPostLogoutResponse, @@ -469,6 +473,89 @@ export const forgotPassword = { validity: validity2, } +/** + * Upload one remote URL file for a HITL human input form + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post13 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postFormHumanInputFilesRemoteUpload', + path: '/form/human_input/files/remote-upload', + summary: 'Upload one remote URL file for a HITL human input form', + tags: ['web'], + }) + .output(zPostFormHumanInputFilesRemoteUploadResponse) + +export const remoteUpload = { + post: post13, +} + +/** + * Upload one local file for a HITL human input form + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post14 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postFormHumanInputFilesUpload', + path: '/form/human_input/files/upload', + summary: 'Upload one local file for a HITL human input form', + tags: ['web'], + }) + .output(zPostFormHumanInputFilesUploadResponse) + +export const upload2 = { + post: post14, +} + +export const files2 = { + remoteUpload, + upload: upload2, +} + +/** + * Issue an upload token for a human input form + * + * POST /api/form/human_input//upload-token + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post15 = oc + .route({ + deprecated: true, + description: + 'POST /api/form/human_input//upload-token\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postFormHumanInputByFormTokenUploadToken', + path: '/form/human_input/{form_token}/upload-token', + summary: 'Issue an upload token for a human input form', + tags: ['web'], + }) + .input(z.object({ params: zPostFormHumanInputByFormTokenUploadTokenPath })) + .output(zPostFormHumanInputByFormTokenUploadTokenResponse) + +export const uploadToken = { + post: post15, +} + /** * Get human input form definition by token * @@ -510,7 +597,7 @@ export const get2 = oc * * @deprecated */ -export const post13 = oc +export const post16 = oc .route({ deprecated: true, description: @@ -527,10 +614,12 @@ export const post13 = oc export const byFormToken = { get: get2, - post: post13, + post: post16, + uploadToken, } export const humanInput = { + files: files2, byFormToken, } @@ -561,7 +650,7 @@ export const status = { * * Authenticate user for web application access */ -export const post14 = oc +export const post17 = oc .route({ description: 'Authenticate user for web application access', inputStructure: 'detailed', @@ -575,14 +664,14 @@ export const post14 = oc .output(zPostLoginResponse) export const login = { - post: post14, + post: post17, status, } /** * Logout user from web application */ -export const post15 = oc +export const post18 = oc .route({ description: 'Logout user from web application', inputStructure: 'detailed', @@ -594,7 +683,7 @@ export const post15 = oc .output(zPostLogoutResponse) export const logout = { - post: post15, + post: post18, } /** @@ -604,7 +693,7 @@ export const logout = { * * @deprecated */ -export const post16 = oc +export const post19 = oc .route({ deprecated: true, description: @@ -624,7 +713,7 @@ export const post16 = oc .output(zPostMessagesByMessageIdFeedbacksResponse) export const feedbacks = { - post: post16, + post: post19, } /** @@ -813,7 +902,7 @@ export const passport = { * * @deprecated */ -export const post17 = oc +export const post20 = oc .route({ deprecated: true, description: @@ -828,8 +917,8 @@ export const post17 = oc }) .output(zPostRemoteFilesUploadResponse) -export const upload2 = { - post: post17, +export const upload3 = { + post: post20, } /** @@ -869,7 +958,7 @@ export const byUrl = { } export const remoteFiles = { - upload: upload2, + upload: upload3, byUrl, } @@ -921,7 +1010,7 @@ export const get11 = oc * * @deprecated */ -export const post18 = oc +export const post21 = oc .route({ deprecated: true, description: @@ -937,7 +1026,7 @@ export const post18 = oc export const savedMessages = { get: get11, - post: post18, + post: post21, byMessageId: byMessageId2, } @@ -1014,7 +1103,7 @@ export const systemFeatures = { * * @deprecated */ -export const post19 = oc +export const post22 = oc .route({ deprecated: true, description: @@ -1030,7 +1119,7 @@ export const post19 = oc .output(zPostTextToAudioResponse) export const textToAudio = { - post: post19, + post: post22, } /** @@ -1123,7 +1212,7 @@ export const workflow = { * * @deprecated */ -export const post20 = oc +export const post23 = oc .route({ deprecated: true, description: @@ -1139,7 +1228,7 @@ export const post20 = oc .output(zPostWorkflowsRunResponse) export const run = { - post: post20, + post: post23, } /** @@ -1147,7 +1236,7 @@ export const run = { * * Stop a running workflow task. */ -export const post21 = oc +export const post24 = oc .route({ description: 'Stop a running workflow task.', inputStructure: 'detailed', @@ -1161,7 +1250,7 @@ export const post21 = oc .output(zPostWorkflowsTasksByTaskIdStopResponse) export const stop3 = { - post: post21, + post: post24, } export const byTaskId4 = { diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index b08f37220851ae..9c0f9ec4aad4f7 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -128,6 +128,15 @@ export type ForgotPasswordSendPayload = { language?: string | null } +export type HumanInputRemoteFileUploadPayload = { + url: string +} + +export type HumanInputUploadTokenResponse = { + expires_at: number + upload_token: string +} + export type LicenseLimitationModel = { enabled: boolean limit: number @@ -819,6 +828,38 @@ export type PostForgotPasswordValidityResponses = { export type PostForgotPasswordValidityResponse = PostForgotPasswordValidityResponses[keyof PostForgotPasswordValidityResponses] +export type PostFormHumanInputFilesRemoteUploadData = { + body?: never + path?: never + query?: never + url: '/form/human_input/files/remote-upload' +} + +export type PostFormHumanInputFilesRemoteUploadResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostFormHumanInputFilesRemoteUploadResponse + = PostFormHumanInputFilesRemoteUploadResponses[keyof PostFormHumanInputFilesRemoteUploadResponses] + +export type PostFormHumanInputFilesUploadData = { + body?: never + path?: never + query?: never + url: '/form/human_input/files/upload' +} + +export type PostFormHumanInputFilesUploadResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostFormHumanInputFilesUploadResponse + = PostFormHumanInputFilesUploadResponses[keyof PostFormHumanInputFilesUploadResponses] + export type GetFormHumanInputByFormTokenData = { body?: never path: { @@ -855,6 +896,24 @@ export type PostFormHumanInputByFormTokenResponses = { export type PostFormHumanInputByFormTokenResponse = PostFormHumanInputByFormTokenResponses[keyof PostFormHumanInputByFormTokenResponses] +export type PostFormHumanInputByFormTokenUploadTokenData = { + body?: never + path: { + form_token: string + } + query?: never + url: '/form/human_input/{form_token}/upload-token' +} + +export type PostFormHumanInputByFormTokenUploadTokenResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostFormHumanInputByFormTokenUploadTokenResponse + = PostFormHumanInputByFormTokenUploadTokenResponses[keyof PostFormHumanInputByFormTokenUploadTokenResponses] + export type PostLoginData = { body: LoginPayload path?: never diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index d1e8ab7600d75f..81ea7c420f5771 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -172,6 +172,21 @@ export const zForgotPasswordSendPayload = z.object({ language: z.string().nullish(), }) +/** + * HumanInputRemoteFileUploadPayload + */ +export const zHumanInputRemoteFileUploadPayload = z.object({ + url: z.url().min(1).max(2083), +}) + +/** + * HumanInputUploadTokenResponse + */ +export const zHumanInputUploadTokenResponse = z.object({ + expires_at: z.int(), + upload_token: z.string(), +}) + /** * LicenseLimitationModel * @@ -529,6 +544,16 @@ export const zPostForgotPasswordValidityBody = zForgotPasswordCheckPayload */ export const zPostForgotPasswordValidityResponse = zVerificationTokenResponse +/** + * Success + */ +export const zPostFormHumanInputFilesRemoteUploadResponse = z.record(z.string(), z.unknown()) + +/** + * Success + */ +export const zPostFormHumanInputFilesUploadResponse = z.record(z.string(), z.unknown()) + export const zGetFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), }) @@ -547,6 +572,15 @@ export const zPostFormHumanInputByFormTokenPath = z.object({ */ export const zPostFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) +export const zPostFormHumanInputByFormTokenUploadTokenPath = z.object({ + form_token: z.string(), +}) + +/** + * Success + */ +export const zPostFormHumanInputByFormTokenUploadTokenResponse = z.record(z.string(), z.unknown()) + export const zPostLoginBody = zLoginPayload /** diff --git a/web/__mocks__/base-ui-select.tsx b/web/__mocks__/base-ui-select.tsx index 76551644192f7c..8788f40e7570df 100644 --- a/web/__mocks__/base-ui-select.tsx +++ b/web/__mocks__/base-ui-select.tsx @@ -26,15 +26,21 @@ export const SelectTrigger = ({ children, ...props }: React.ButtonHTMLAttributes & { children?: ReactNode }) => ( - ) export const SelectValue = ({ placeholder }: { placeholder?: ReactNode }) => <>{placeholder} -export const SelectContent = ({ children }: { children?: ReactNode }) => ( -
{children}
+export const SelectContent = ({ + children, + popupClassName, +}: { + children?: ReactNode + popupClassName?: string +}) => ( +
{children}
) export const SelectItem = ({ diff --git a/web/__tests__/base/file-uploader-flow.test.tsx b/web/__tests__/base/file-uploader-flow.test.tsx index 81dccedfe5113c..c77c92ad31747d 100644 --- a/web/__tests__/base/file-uploader-flow.test.tsx +++ b/web/__tests__/base/file-uploader-flow.test.tsx @@ -11,6 +11,7 @@ const mockUploadRemoteFileInfo = vi.fn() vi.mock('@/next/navigation', () => ({ useParams: () => ({}), + usePathname: () => '/', })) vi.mock('@/service/common', () => ({ diff --git a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx new file mode 100644 index 00000000000000..ac919ffb0517ee --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx @@ -0,0 +1,357 @@ +import type { FormData } from '../form' +import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' +import { act, render, renderHook, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' +import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import FormContent from '../form' +import { useFormSubmit } from '../use-form-submit' + +const mockSubmitForm = vi.hoisted(() => vi.fn()) +const mockUseGetHumanInputForm = vi.hoisted(() => vi.fn()) +const mockContentItemState = vi.hoisted(() => ({ + staleAttachmentInputChange: undefined as ((name: string, value: unknown) => void) | undefined, + uploadedFile: { + id: 'file-1', + name: 'review.pdf', + size: 128, + type: 'document', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: 'upload-file-1', + }, + uploadingFile: { + id: 'file-1', + name: 'review.pdf', + size: 128, + type: 'document', + progress: 50, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: undefined, + }, +})) + +vi.mock('@/next/navigation', () => ({ + useParams: () => ({ token: 'token-123' }), +})) + +vi.mock('@/service/use-share', () => ({ + useGetHumanInputForm: (...args: unknown[]) => mockUseGetHumanInputForm(...args), + useSubmitHumanInputForm: () => ({ + mutate: mockSubmitForm, + isPending: false, + }), +})) + +vi.mock('@/hooks/use-document-title', () => ({ + __esModule: true, + default: vi.fn(), +})) + +vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({ + __esModule: true, + default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => { + const isSummaryField = content.includes('summary') + const isAttachmentField = content.includes('attachments') + + if (isAttachmentField && !mockContentItemState.staleAttachmentInputChange) + mockContentItemState.staleAttachmentInputChange = onInputChange + + return ( +
+ {content} + {isSummaryField && ( + <> + + + + )} + {isAttachmentField && ( + <> + + + + )} +
+ ) + }, +})) + +vi.mock('@/app/components/base/chat/chat/answer/human-input-content/expiration-time', () => ({ + __esModule: true, + default: () =>
expiration-time
, +})) + +vi.mock('@/app/components/base/loading', () => ({ + __esModule: true, + default: () =>
loading
, +})) + +vi.mock('@/app/components/base/logo/dify-logo', () => ({ + __esModule: true, + default: () =>
dify-logo
, +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + __esModule: true, + default: () =>
app-icon
, +})) + +describe('Human input share form', () => { + const formData: FormData = { + site: { + site: { + title: 'Review App', + icon_type: 'emoji', + icon: 'R', + icon_background: '#fff', + icon_url: '', + default_language: 'en-US', + description: '', + copyright: '', + privacy_policy: '', + custom_disclaimer: '', + prompt_public: false, + use_icon_as_answer_icon: false, + }, + }, + form_content: '{{#$output.summary#}} {{#$output.attachments#}}', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'summary', + default: { + type: 'constant', + value: 'initial summary', + selector: [], + }, + }, + { + type: InputVarType.multiFiles, + output_variable_name: 'attachments', + allowed_file_extensions: ['.pdf'], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_upload_methods: [TransferMethod.local_file], + number_limits: 3, + }, + ], + resolved_default_values: {}, + user_actions: [ + { + id: 'approve', + title: 'Approve', + button_style: UserActionButtonType.Primary, + }, + ], + expiration_time: 60, + } + + beforeEach(() => { + vi.clearAllMocks() + mockContentItemState.staleAttachmentInputChange = undefined + mockUseGetHumanInputForm.mockReturnValue({ + data: formData, + isLoading: false, + error: null, + }) + }) + + it('should render the loading state while the form is being fetched', () => { + mockUseGetHumanInputForm.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + }) + + render() + + expect(screen.getByText('loading')).toBeInTheDocument() + }) + + it('should render status cards for terminal fetch states', () => { + const cases = [ + { + error: { code: 'human_input_form_expired' }, + title: 'share.humanInput.sorry', + subtitle: 'share.humanInput.expired', + submissionID: true, + }, + { + error: { code: 'human_input_form_submitted' }, + title: 'share.humanInput.sorry', + subtitle: 'share.humanInput.completed', + submissionID: true, + }, + { + error: { code: 'web_form_rate_limit_exceeded' }, + title: 'share.humanInput.rateLimitExceeded', + subtitle: undefined, + submissionID: false, + }, + { + error: null, + title: 'share.humanInput.formNotFound', + subtitle: undefined, + submissionID: false, + }, + ] + + cases.forEach(({ error, title, subtitle, submissionID }) => { + mockUseGetHumanInputForm.mockReturnValue({ + data: undefined, + isLoading: false, + error, + }) + const { unmount } = render() + + expect(screen.getByText(title)).toBeInTheDocument() + if (subtitle) + expect(screen.getByText(subtitle)).toBeInTheDocument() + else + expect(screen.queryByText('share.humanInput.expired')).not.toBeInTheDocument() + + if (submissionID) + expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument() + else + expect(screen.queryByText(/share\.humanInput\.submissionID/)).not.toBeInTheDocument() + + expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument() + expect(screen.getByText('dify-logo')).toBeInTheDocument() + unmount() + }) + }) + + it('submits typed human input values through the share form mutation', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: 'share-update-summary' })) + await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) + await user.click(screen.getByRole('button', { name: 'Approve' })) + + expect(mockSubmitForm).toHaveBeenCalledWith({ + token: 'token-123', + data: { + action: 'approve', + inputs: { + summary: 'updated summary', + attachments: [{ + type: 'document', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }], + }, + }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + })) + }) + + it('should show the success status after the submit mutation succeeds', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: 'share-update-summary' })) + await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) + await user.click(screen.getByRole('button', { name: 'Approve' })) + + const options = mockSubmitForm.mock.calls[0]![1] as { onSuccess: () => void } + act(() => { + options.onSuccess() + }) + + expect(screen.getByText('share.humanInput.thanks')).toBeInTheDocument() + expect(screen.getByText('share.humanInput.recorded')).toBeInTheDocument() + expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument() + }) + + it('should submit empty inputs when there are no form values to process', () => { + const { result } = renderHook(() => useFormSubmit('token-empty')) + + act(() => { + result.current.submit( + undefined as unknown as Record, + 'reject', + [], + ) + }) + + expect(mockSubmitForm).toHaveBeenCalledWith({ + token: 'token-empty', + data: { + action: 'reject', + inputs: {}, + }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + })) + }) + + it('should keep initialized defaults when file upload uses the initial change callback', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) + await user.click(screen.getByRole('button', { name: 'Approve' })) + + expect(mockSubmitForm).toHaveBeenCalledWith({ + token: 'token-123', + data: { + action: 'approve', + inputs: { + summary: 'initial summary', + attachments: [{ + type: 'document', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }], + }, + }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + })) + }) + + it('should disable action buttons until every required field is filled and files are uploaded', async () => { + const user = userEvent.setup() + + render() + + const approveButton = screen.getByRole('button', { name: 'Approve' }) + expect(approveButton).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'share-uploading-attachments' })) + expect(approveButton).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) + expect(approveButton).toBeEnabled() + + await user.click(screen.getByRole('button', { name: 'share-clear-summary' })) + expect(approveButton).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'share-update-summary' })) + expect(approveButton).toBeEnabled() + }) +}) diff --git a/web/app/(humanInputLayout)/form/[token]/__tests__/page.spec.tsx b/web/app/(humanInputLayout)/form/[token]/__tests__/page.spec.tsx new file mode 100644 index 00000000000000..b4026a1f550367 --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/__tests__/page.spec.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import FormPage from '../page' + +vi.mock('../form', () => ({ + __esModule: true, + default: () =>
form-content
, +})) + +describe('Human input share form page', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the form content inside the page shell', () => { + render() + + expect(screen.getByText('form-content')).toBeInTheDocument() + }) +}) diff --git a/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx b/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx new file mode 100644 index 00000000000000..981d0c59f45b1a --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import DifyLogo from '@/app/components/base/logo/dify-logo' + +type FormStatusCardProps = { + iconClassName: string + title: ReactNode + subtitle?: ReactNode + submissionID?: string +} + +const FormStatusCard = ({ + iconClassName, + title, + subtitle, + submissionID, +}: FormStatusCardProps) => { + const { t } = useTranslation() + + return ( +
+
+
+
+ +
+
+
{title}
+ {!!subtitle && ( +
{subtitle}
+ )} +
+ {submissionID && ( +
+ {t('humanInput.submissionID', { id: submissionID, ns: 'share' })} +
+ )} +
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+ +
+
+
+
+ ) +} + +export default FormStatusCard diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index 76dcd24293e9dc..55491462cee978 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -1,34 +1,23 @@ 'use client' -import type { ButtonProps } from '@langgenius/dify-ui/button' import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types' import type { SiteInfo } from '@/models/share' import type { HumanInputFormError } from '@/service/use-share' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { - RiCheckboxCircleFill, - RiErrorWarningFill, - RiInformation2Fill, -} from '@remixicon/react' -import { produce } from 'immer' +import type { HumanInputResolvedValue } from '@/types/workflow' import * as React from 'react' -import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import AppIcon from '@/app/components/base/app-icon' -import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item' -import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time' -import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils' import Loading from '@/app/components/base/loading' -import DifyLogo from '@/app/components/base/logo/dify-logo' import useDocumentTitle from '@/hooks/use-document-title' import { useParams } from '@/next/navigation' -import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share' +import { useGetHumanInputForm } from '@/service/use-share' +import FormStatusCard from './form-status-card' +import LoadedFormContent from './loaded-form-content' +import { useFormSubmit } from './use-form-submit' export type FormData = { site: { site: SiteInfo } form_content: string inputs: FormInputItem[] - resolved_default_values: Record + resolved_default_values: Record user_actions: UserAction[] expiration_time: number } @@ -39,58 +28,13 @@ const FormContent = () => { const { token } = useParams<{ token: string }>() useDocumentTitle('') - const [inputs, setInputs] = useState>({}) - const [success, setSuccess] = useState(false) - - const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm() - const { data: formData, isLoading, error } = useGetHumanInputForm(token) + const { isSubmitting, submit, success } = useFormSubmit(token) const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired' const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted' const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded' - const splitByOutputVar = (content: string): string[] => { - const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g - const parts = content.split(outputVarRegex) - return parts.filter(part => part.length > 0) - } - - const contentList = useMemo(() => { - if (!formData?.form_content) - return [] - return splitByOutputVar(formData.form_content) - }, [formData?.form_content]) - - useEffect(() => { - if (!formData?.inputs) - return - const initialInputs: Record = {} - formData.inputs.forEach((item) => { - initialInputs[item.output_variable_name] = item.default.type === 'variable' ? formData.resolved_default_values[item.output_variable_name] || '' : item.default.value - }) - setInputs(initialInputs) - }, [formData?.inputs, formData?.resolved_default_values]) - - // use immer - const handleInputsChange = (name: string, value: string) => { - const newInputs = produce(inputs, (draft) => { - draft[name] = value - }) - setInputs(newInputs) - } - - const submit = (actionID: string) => { - submitForm( - { token, data: { inputs, action: actionID } }, - { - onSuccess: () => { - setSuccess(true) - }, - }, - ) - } - if (isLoading) { return ( @@ -99,190 +43,62 @@ const FormContent = () => { if (success) { return ( -
-
-
-
- -
-
-
{t('humanInput.thanks', { ns: 'share' })}
-
{t('humanInput.recorded', { ns: 'share' })}
-
-
{t('humanInput.submissionID', { id: token, ns: 'share' })}
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
- -
-
-
-
+ ) } if (expired) { return ( -
-
-
-
- -
-
-
{t('humanInput.sorry', { ns: 'share' })}
-
{t('humanInput.expired', { ns: 'share' })}
-
-
{t('humanInput.submissionID', { id: token, ns: 'share' })}
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
- -
-
-
-
+ ) } if (submitted) { return ( -
-
-
-
- -
-
-
{t('humanInput.sorry', { ns: 'share' })}
-
{t('humanInput.completed', { ns: 'share' })}
-
-
{t('humanInput.submissionID', { id: token, ns: 'share' })}
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
- -
-
-
-
+ ) } if (rateLimitExceeded) { return ( -
-
-
-
- -
-
-
{t('humanInput.rateLimitExceeded', { ns: 'share' })}
-
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
- -
-
-
-
+ ) } if (!formData) { return ( -
-
-
-
- -
-
-
{t('humanInput.formNotFound', { ns: 'share' })}
-
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
- -
-
-
-
+ ) } - const site = formData.site.site - return ( -
-
- -
{site.title}
-
-
-
- {contentList.map((content, index) => ( - - ))} -
- {formData.user_actions.map((action: UserAction) => ( - - ))} -
- -
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
- -
-
-
-
+ ) } diff --git a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx new file mode 100644 index 00000000000000..a0e1db23c2da8d --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx @@ -0,0 +1,98 @@ +import type { ButtonProps } from '@langgenius/dify-ui/button' +import type { FormData } from './form' +import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' +import type { UserAction } from '@/app/components/workflow/nodes/human-input/types' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { produce } from 'immer' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item' +import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time' +import { getButtonStyle, getRenderedFormInputs, hasInvalidRequiredHumanInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils' +import DifyLogo from '@/app/components/base/logo/dify-logo' + +type LoadedFormContentProps = { + formData: FormData + isSubmitting: boolean + onSubmit: (inputs: Record, actionID: string, formInputs: FormData['inputs']) => void +} + +const LoadedFormContent = ({ + formData, + isSubmitting, + onSubmit, +}: LoadedFormContentProps) => { + const { t } = useTranslation() + const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content) + const [inputs, setInputs] = useState>(() => + initializeInputs(renderedFormInputs, formData.resolved_default_values), + ) + + const contentList = useMemo(() => { + return splitByOutputVar(formData.form_content) + }, [formData.form_content]) + + const handleInputsChange = (name: string, value: HumanInputFieldValue) => { + setInputs(prevInputs => produce(prevInputs, (draft) => { + draft[name] = value + })) + } + + const submit = (actionID: string) => { + onSubmit(inputs, actionID, formData.inputs) + } + + const isActionDisabled = isSubmitting || hasInvalidRequiredHumanInput(renderedFormInputs, inputs) + const site = formData.site.site + + return ( +
+
+ +
{site.title}
+
+
+
+ {contentList.map((content, index) => ( + + ))} +
+ {formData.user_actions.map((action: UserAction) => ( + + ))} +
+ +
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+ +
+
+
+
+ ) +} + +export default LoadedFormContent diff --git a/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts b/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts new file mode 100644 index 00000000000000..de018b28ed37b8 --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts @@ -0,0 +1,33 @@ +import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' +import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types' +import { useCallback, useState } from 'react' +import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils' +import { useSubmitHumanInputForm } from '@/service/use-share' + +export const useFormSubmit = (token: string) => { + const [success, setSuccess] = useState(false) + const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm() + + const submit = useCallback((inputs: Record, actionID: string, formInputs: FormInputItem[]) => { + submitForm( + { + token, + data: { + inputs: getProcessedHumanInputFormInputs(formInputs, inputs) || {}, + action: actionID, + }, + }, + { + onSuccess: () => { + setSuccess(true) + }, + }, + ) + }, [submitForm, token]) + + return { + isSubmitting, + submit, + success, + } +} diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx index f1356c9b6190c5..00c96c3e007d28 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx @@ -1,5 +1,6 @@ /* eslint-disable ts/no-explicit-any */ -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import TypeSelector from '../type-select' vi.mock('@langgenius/dify-ui/select', () => import('@/__mocks__/base-ui-select')) @@ -9,8 +10,9 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', })) describe('TypeSelector', () => { - it('should toggle open state and select a new variable type', () => { + it('should select a new variable type when an option is clicked', async () => { const onSelect = vi.fn() + const user = userEvent.setup() render( { />, ) - fireEvent.click(screen.getByRole('button')) - fireEvent.click(screen.getByText('Number')) + await user.click(screen.getByRole('combobox')) + const [, numberOption] = await screen.findAllByRole('option') + await user.click(numberOption!) expect(onSelect).toHaveBeenCalledWith({ value: 'number', name: 'Number' }) }) + + it('should size popup content to match the trigger width', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('combobox')) + + const [, numberOption] = await screen.findAllByRole('option') + const popup = numberOption!.closest('[data-side]') + + expect(popup).toHaveClass('w-(--anchor-width)') + }) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx index acd3253f6bbdd2..352b3139ff9ff1 100644 --- a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx @@ -59,9 +59,10 @@ const TypeSelector: FC = ({
{selectedItem?.name} @@ -73,7 +74,7 @@ const TypeSelector: FC = ({ {items.map((item: Item) => ( diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index 78ab1b5b320900..e383bbd902d090 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type' import type { FeedbackType } from '@/app/components/base/chat/chat/type' import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { SiteInfo } from '@/models/share' @@ -178,7 +179,7 @@ const GenerationItem: FC = ({ // eslint-disable-next-line react/set-state-in-effect setCurrentTab(getDefaultGenerationTab(workflowProcessData)) }, [workflowProcessData]) - const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record, action: string }) => { + const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => { if (appSourceType === AppSourceType.installedApp) await submitHumanInputFormService(formToken, formData) else diff --git a/web/app/components/app/text-generate/item/workflow-body.tsx b/web/app/components/app/text-generate/item/workflow-body.tsx index d5a076ace9399b..f194c00092fb63 100644 --- a/web/app/components/app/text-generate/item/workflow-body.tsx +++ b/web/app/components/app/text-generate/item/workflow-body.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type' import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' @@ -17,7 +18,7 @@ type WorkflowBodyProps = { depth: number hideProcessDetail?: boolean isError: boolean - onSubmitHumanInputForm: (formToken: string, formData: { inputs: Record, action: string }) => Promise + onSubmitHumanInputForm: (formToken: string, formData: HumanInputFormSubmitData) => Promise onSwitchTab: (tab: string) => void showResultTabs: boolean siteInfo: SiteInfo | null diff --git a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx index e2dee20caddc98..35478bae7b6e9b 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx @@ -1076,6 +1076,10 @@ describe('useChatWithHistory', () => { await waitFor(() => { expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0) }) + + const answerNode = result!.current.appPrevChatTree[0]?.children?.[0] + expect(answerNode?.humanInputFormDataList).toHaveLength(1) + expect(answerNode?.workflow_run_id).toBe('wf-run-1') }) it('should set workflow_run_id for normal messages with submitted human_input', async () => { @@ -1114,6 +1118,75 @@ describe('useChatWithHistory', () => { await waitFor(() => { expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0) }) + + const answerNode = result!.current.appPrevChatTree[0]?.children?.[0] + expect(answerNode?.humanInputFilledFormDataList).toHaveLength(1) + }) + + it('should parse human input payloads regardless of message status', async () => { + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1' })], + }) + const chatListData = { + data: [ + { + id: 'msg-status-agnostic', + query: 'Needs review', + answer: 'Pending follow-up', + message_files: [], + feedback: null, + retriever_resources: [], + agent_thoughts: null, + parent_message_id: null, + inputs: {}, + status: 'error', + extra_contents: [ + { + type: 'human_input', + submitted: true, + form_definition: { + form_id: 'form-1', + node_id: 'node-1', + node_title: 'Human Input', + form_content: '{{#$output.summary#}}', + inputs: [], + actions: [], + form_token: 'token-1', + resolved_default_values: {}, + display_in_ui: true, + expiration_time: 0, + }, + workflow_run_id: 'wf-run-status-agnostic', + form_submission_data: { + node_id: 'node-1', + node_title: 'Human Input', + rendered_content: 'Submitted summary', + action_id: 'submit', + action_text: 'Submit', + submitted_data: { + summary: 'approved', + }, + }, + }, + ], + }, + ], + } + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue(chatListData) + + const { result } = await renderWithClient(() => useChatWithHistory()) + + await waitFor(() => { + expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0) + }) + + const answerNode = result!.current.appPrevChatTree[0]?.children?.[0] + expect(answerNode?.humanInputFormDataList).toHaveLength(0) + expect(answerNode?.humanInputFilledFormDataList).toHaveLength(1) + expect(answerNode?.humanInputFilledFormDataList?.[0]?.form_content).toBe('{{#$output.summary#}}') + expect(answerNode?.humanInputFilledFormDataList?.[0]?.inputs).toEqual([]) + expect(answerNode?.workflow_run_id).toBe('wf-run-status-agnostic') }) it('should return empty appPrevChatTree when there is no currentConversationId', async () => { @@ -1835,6 +1908,15 @@ describe('useChatWithHistory', () => { expect(messageWithFiles?.message_files).toHaveLength(1) expect(messageWithFiles?.children?.[0]?.message_files).toHaveLength(1) expect(messageWithFiles?.children?.[0]?.agent_thoughts?.[0]?.message_files).toHaveLength(1) + + const normalAnswerNode = messageWithFiles?.children?.[0] + const pausedAnswerNode = result!.current.appPrevChatTree.find(item => item.id === 'question-msg-paused-branch')?.children?.[0] + + expect(normalAnswerNode?.humanInputFilledFormDataList).toHaveLength(1) + expect(normalAnswerNode?.humanInputFormDataList).toHaveLength(0) + expect(pausedAnswerNode?.humanInputFormDataList).toHaveLength(1) + expect(pausedAnswerNode?.humanInputFilledFormDataList).toHaveLength(0) + expect(pausedAnswerNode?.workflow_run_id).toBe('wf-run-branch') }) }) diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index 223c35f6dd16c9..5b4c80d3a8e5dd 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -18,6 +18,7 @@ import { AppSourceType, delConversation, pinConversation, renameConversation, un import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share' import { TransferMethod } from '@/types/app' import { addFileInfos, sortAgentSorts } from '../../../tools/utils' +import { enrichSubmittedHumanInputFormData } from '../chat/answer/human-input-content/submitted-utils' import { CONVERSATION_ID_INFO } from '../constants' import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils' @@ -36,21 +37,30 @@ function getFormattedChatList(messages: any[]) { const humanInputFormDataList: HumanInputFormData[] = [] const humanInputFilledFormDataList: HumanInputFilledFormData[] = [] let workflowRunId = '' - if (item.status === 'paused') { - item.extra_contents?.forEach((content: ExtraContent) => { - if (content.type === 'human_input' && !content.submitted) { - humanInputFormDataList.push(content.form_definition) - workflowRunId = content.workflow_run_id - } - }) - } - else if (item.status === 'normal') { - item.extra_contents?.forEach((content: ExtraContent) => { - if (content.type === 'human_input' && content.submitted) { - humanInputFilledFormDataList.push(content.form_submission_data) - } - }) - } + item.extra_contents?.forEach((content: ExtraContent) => { + if (content.type !== 'human_input') + return + + const formDefinition = 'form_definition' in content ? content.form_definition : undefined + if (!content.submitted) { + if (!formDefinition) + return + humanInputFormDataList.push(formDefinition) + workflowRunId = content.workflow_run_id || workflowRunId + return + } + + if (!('form_submission_data' in content) || !content.form_submission_data) + return + const currentFormIndex = humanInputFormDataList.findIndex(item => item.node_id === content.form_submission_data.node_id) + const requiredFormData = formDefinition || (currentFormIndex > -1 + ? humanInputFormDataList[currentFormIndex] + : undefined) + if (currentFormIndex > -1) + humanInputFormDataList.splice(currentFormIndex, 1) + workflowRunId = content.workflow_run_id || workflowRunId + humanInputFilledFormDataList.push(enrichSubmittedHumanInputFormData(content.form_submission_data, requiredFormData)) + }) newChatList.push({ id: item.id, content: item.answer, diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index 738ab79bca8551..3a63ca0886e3d1 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -411,7 +411,7 @@ describe('useChat', () => { // Human input required callbacks.onHumanInputRequired({ data: { node_id: 'n-human' } }) - callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true } }) // update existing + callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true, form_content: '{{#$output.answer#}}', inputs: [] } }) // update existing // setTimeout for timeout form callbacks.onHumanInputFormTimeout({ data: { node_id: 'n-human', expiration_time: 123456 } }) @@ -437,6 +437,10 @@ describe('useChat', () => { const lastResponse = result.current.chatList[1] expect(lastResponse!.humanInputFormDataList).toHaveLength(0) // Removed when filled expect(lastResponse!.humanInputFilledFormDataList).toHaveLength(2) + expect(lastResponse!.humanInputFilledFormDataList![0]).toEqual(expect.objectContaining({ + form_content: '{{#$output.answer#}}', + inputs: [], + })) expect(sseGet).toHaveBeenCalled() // from workflowPaused expect(lastResponse!.annotation?.id).toBe('anno-1') expect(lastResponse!.content).toBe('Replaced content') diff --git a/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx index 37556550ca1941..09cced154ffed4 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx @@ -1,5 +1,6 @@ import type { HumanInputFilledFormData } from '@/types/workflow' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { describe, expect, it } from 'vitest' import HumanInputFilledFormList from '../human-input-filled-form-list' @@ -12,13 +13,15 @@ const createFormData = ( ): HumanInputFilledFormData => ({ node_id: 'node-1', node_title: 'Node Title', + rendered_content: 'fallback content', + action_id: 'approve', + action_text: 'Approve', + submitted_data: { + summary: 'Approved', + }, - // 👇 IMPORTANT - // DO NOT guess properties like `inputs` - // Only include fields that actually exist in your project type. - // Leave everything else empty via spread. ...overrides, -} as HumanInputFilledFormData) +}) describe('HumanInputFilledFormList', () => { it('renders nothing when list is empty', () => { @@ -27,12 +30,15 @@ describe('HumanInputFilledFormList', () => { expect(screen.queryByText('Node Title')).not.toBeInTheDocument() }) - it('renders one form item', () => { + it('renders one form item', async () => { + const user = userEvent.setup() const data = [createFormData()] render() expect(screen.getByText('Node Title')).toBeInTheDocument() + await user.click(screen.getByTestId('expand-icon')) + expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Approved') }) it('renders multiple form items', () => { diff --git a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx index 816fd333413bc3..bd3de0cec3a739 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx @@ -248,6 +248,13 @@ describe('Operation', () => { expect(screen.getByTestId('log-btn'))!.toBeInTheDocument() }) + it('should keep hover-only controls visible when a descendant popup is open', () => { + renderOperation({ ...baseProps, showPromptLog: true }) + + expect(screen.getByTestId('operation-actions')).toHaveClass('group-has-[[data-popup-open]]:flex') + expect(screen.getByTestId('log-btn').parentElement).toHaveClass('group-has-[[data-popup-open]]:block') + }) + it('should not show prompt log for opening statements', () => { const item = { ...baseItem, isOpeningStatement: true } renderOperation({ ...baseProps, item, showPromptLog: true }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx index b1a6ec51aeed88..a879aef9feb0f7 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx @@ -1,13 +1,24 @@ +import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' +import { TransferMethod } from '@/types/app' import ContentItem from '../content-item' vi.mock('@/app/components/base/markdown', () => ({ Markdown: ({ content }: { content: string }) =>
{content}
, })) +vi.mock('../field-renderer', () => ({ + __esModule: true, + default: ({ field, onChange }: { field: FormInputItem, onChange: (value: unknown) => void }) => ( + + ), +})) + describe('ContentItem', () => { const mockOnInputChange = vi.fn() const mockFormInputFields: FormInputItem[] = [ @@ -49,9 +60,8 @@ describe('ContentItem', () => { />, ) - const textarea = screen.getByTestId('content-item-textarea') + const textarea = screen.getByTestId('renderer-paragraph') expect(textarea).toBeInTheDocument() - expect(textarea).toHaveValue('Initial bio') expect(screen.queryByTestId('mock-markdown')).not.toBeInTheDocument() }) @@ -66,10 +76,9 @@ describe('ContentItem', () => { />, ) - const textarea = screen.getByTestId('content-item-textarea') - await user.type(textarea, 'x') + await user.click(screen.getByTestId('renderer-paragraph')) - expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'Initial biox') + expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'updated value') }) it('should render nothing if field name is valid but not found in formInputFields', () => { @@ -85,18 +94,20 @@ describe('ContentItem', () => { expect(container.firstChild).toBeNull() }) - it('should render nothing if input type is not supported', () => { - const { container } = render( + it('should delegate select fields to the shared renderer', async () => { + const user = userEvent.setup() + + render( { />, ) - expect(container.querySelector('[data-testid="content-item-textarea"]')).not.toBeInTheDocument() - expect(container.querySelector('.py-3')?.textContent).toBe('') + await user.click(screen.getByTestId('renderer-select')) + + expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'select') + }) + + it('should delegate single-file fields to the shared renderer', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByTestId('renderer-file')) + + expect(mockOnInputChange).toHaveBeenCalledWith('attachment', 'file') + }) + + it('should delegate file-list fields to the shared renderer', async () => { + const user = userEvent.setup() + const existingFiles: FileEntity[] = [{ + id: 'file-1', + name: 'brief.pdf', + size: 128, + type: 'document', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + }] + + render( + , + ) + + await user.click(screen.getByTestId('renderer-file-list')) + + expect(mockOnInputChange).toHaveBeenCalledWith('attachments', 'file-list') }) }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx new file mode 100644 index 00000000000000..c69783758360fe --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx @@ -0,0 +1,246 @@ +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import HumanInputFieldRenderer from '../field-renderer' + +vi.mock('@/app/components/base/textarea', () => ({ + __esModule: true, + default: ({ value, onChange }: { value: string, onChange: (event: { target: { value: string } }) => void }) => ( +