This commit is contained in:
QuantumGhost
2025-11-12 05:28:56 +08:00
parent 8b914d9116
commit 4f48b8a57d
23 changed files with 1108 additions and 1828 deletions

View File

@ -4,6 +4,5 @@ Human Input node implementation.
from .entities import HumanInputNodeData
from .human_input_node import HumanInputNode
from .node import HumanInputNode
__all__ = ["HumanInputNode", "HumanInputNodeData"]

View File

@ -2,59 +2,77 @@
Human Input node entities.
"""
import enum
import re
import uuid
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta
from enum import StrEnum
from typing import Any, Literal, Optional, Union
from typing import Annotated, Literal, Optional
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field, field_validator
from core.variables.consts import SELECTORS_LENGTH
from core.workflow.nodes.base import BaseNodeData
from core.workflow.nodes.base.variable_template_parser import VariableTemplateParser
_OUTPUT_VARIABLE_PATTERN = re.compile(r"\{\{#\$outputs\.(?P<field_name>[a-zA-Z_][a-zA-Z0-9_]{0,29})#\}\}")
class HumanInputFormStatus(StrEnum):
"""Status of a human input form."""
WAITING = enum.auto()
EXPIRED = enum.auto()
SUBMITTED = enum.auto()
TIMEOUT = enum.auto()
class DeliveryMethodType(StrEnum):
"""Delivery method types for human input forms."""
WEBAPP = "webapp"
EMAIL = "email"
WEBAPP = enum.auto()
EMAIL = enum.auto()
class ButtonStyle(StrEnum):
"""Button styles for user actions."""
PRIMARY = "primary"
DEFAULT = "default"
ACCENT = "accent"
GHOST = "ghost"
PRIMARY = enum.auto()
DEFAULT = enum.auto()
ACCENT = enum.auto()
GHOST = enum.auto()
class TimeoutUnit(StrEnum):
"""Timeout unit for form expiration."""
HOUR = "hour"
DAY = "day"
HOUR = enum.auto()
DAY = enum.auto()
class FormInputType(StrEnum):
"""Form input types."""
TEXT_INPUT = "text-input"
PARAGRAPH = "paragraph"
TEXT_INPUT = enum.auto()
PARAGRAPH = enum.auto()
class PlaceholderType(StrEnum):
"""Placeholder types for form inputs."""
VARIABLE = "variable"
CONSTANT = "constant"
VARIABLE = enum.auto()
CONSTANT = enum.auto()
class RecipientType(StrEnum):
class EmailRecipientType(StrEnum):
"""Email recipient types."""
MEMBER = "member"
EXTERNAL = "external"
MEMBER = enum.auto()
EXTERNAL = enum.auto()
class WebAppDeliveryConfig(BaseModel):
class _WebAppDeliveryConfig(BaseModel):
"""Configuration for webapp delivery method."""
pass # Empty for webapp delivery
@ -63,25 +81,25 @@ class WebAppDeliveryConfig(BaseModel):
class MemberRecipient(BaseModel):
"""Member recipient for email delivery."""
type: Literal[RecipientType.MEMBER]
type: Literal[EmailRecipientType.MEMBER] = EmailRecipientType.MEMBER
user_id: str
class ExternalRecipient(BaseModel):
"""External recipient for email delivery."""
type: Literal[RecipientType.EXTERNAL]
type: Literal[EmailRecipientType.EXTERNAL] = EmailRecipientType.EXTERNAL
email: str
Recipient = Union[MemberRecipient, ExternalRecipient]
EmailRecipient = Annotated[MemberRecipient | ExternalRecipient, Field(discriminator="type")]
class EmailRecipients(BaseModel):
"""Email recipients configuration."""
whole_workspace: bool = False
items: list[Recipient] = Field(default_factory=list)
items: list[EmailRecipient] = Field(default_factory=list)
class EmailDeliveryConfig(BaseModel):
@ -92,51 +110,54 @@ class EmailDeliveryConfig(BaseModel):
body: str
DeliveryConfig = Union[WebAppDeliveryConfig, EmailDeliveryConfig]
class _DeliveryMethodBase(BaseModel):
"""Base delivery method configuration."""
class DeliveryMethod(BaseModel):
"""Delivery method configuration."""
type: DeliveryMethodType
enabled: bool = True
config: Optional[DeliveryConfig] = None
id: uuid.UUID = Field(default_factory=uuid.uuid4)
@model_validator(mode="after")
def validate_config_type(self):
"""Validate that config matches the delivery method type."""
if self.config is None:
return self
if self.type == DeliveryMethodType.EMAIL:
if isinstance(self.config, dict):
# Try to parse as EmailDeliveryConfig - this will raise validation errors
try:
self.config = EmailDeliveryConfig.model_validate(self.config)
except Exception as e:
# Re-raise with more specific context
raise ValueError(f"Invalid email delivery configuration: {str(e)}")
elif not isinstance(self.config, EmailDeliveryConfig):
raise ValueError("Config must be EmailDeliveryConfig for email delivery method")
elif self.type == DeliveryMethodType.WEBAPP:
if isinstance(self.config, dict):
# Try to parse as WebAppDeliveryConfig
try:
self.config = WebAppDeliveryConfig.model_validate(self.config)
except Exception as e:
raise ValueError(f"Invalid webapp delivery configuration: {str(e)}")
elif not isinstance(self.config, WebAppDeliveryConfig):
raise ValueError("Config must be WebAppDeliveryConfig for webapp delivery method")
class WebAppDeliveryMethod(_DeliveryMethodBase):
"""Webapp delivery method configuration."""
return self
type: Literal[DeliveryMethodType.WEBAPP] = DeliveryMethodType.WEBAPP
# The config field is not used currently.
config: _WebAppDeliveryConfig = Field(default_factory=_WebAppDeliveryConfig)
class EmailDeliveryMethod(_DeliveryMethodBase):
"""Email delivery method configuration."""
type: Literal[DeliveryMethodType.EMAIL] = DeliveryMethodType.EMAIL
config: EmailDeliveryConfig
DeliveryChannelConfig = Annotated[WebAppDeliveryMethod | EmailDeliveryMethod, Field(discriminator="type")]
class FormInputPlaceholder(BaseModel):
"""Placeholder configuration for form inputs."""
# NOTE: Ideally, a discriminated union would be used to model
# FormInputPlaceholder. However, the UI requires preserving the previous
# value when switching between `VARIABLE` and `CONSTANT` types. This
# necessitates retaining all fields, making a discriminated union unsuitable.
type: PlaceholderType
selector: list[str] = Field(default_factory=list) # Used when type is VARIABLE
value: str = "" # Used when type is CONSTANT
# The selector of placeholder variable, used when `type` is `VARIABLE`
selector: Sequence[str] = Field(default_factory=tuple) #
# The value of the placeholder, used when `type` is `CONSTANT`.
# TODO: How should we express JSON values?
value: str = ""
@field_validator("selector")
@classmethod
def _validate_selector(cls, selector: Sequence[str]) -> Sequence[str]:
if len(selector) < SELECTORS_LENGTH:
raise ValueError(f"the length of selector should be at least {SELECTORS_LENGTH}, selector={selector}")
return selector
class FormInput(BaseModel):
@ -147,24 +168,106 @@ class FormInput(BaseModel):
placeholder: Optional[FormInputPlaceholder] = None
_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
class UserAction(BaseModel):
"""User action configuration."""
# id is the identifier for this action.
# It also serves as the identifiers of output handle.
#
# The id must be a valid identifier (satisfy the _IDENTIFIER_PATTERN above.)
id: str
title: str
button_style: ButtonStyle = ButtonStyle.DEFAULT
@field_validator("id")
@classmethod
def _validate_id(cls, value: str) -> str:
if not _IDENTIFIER_PATTERN.match(value):
raise ValueError(
f"'{value}' is not a valid identifier. It must start with a letter or underscore, "
f"and contain only letters, numbers, or underscores."
)
return value
class HumanInputNodeData(BaseNodeData):
"""Human Input node data."""
delivery_methods: list[DeliveryMethod] = Field(default_factory=list)
delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list)
form_content: str = ""
inputs: list[FormInput] = Field(default_factory=list)
user_actions: list[UserAction] = Field(default_factory=list)
timeout: int = 36
timeout_unit: TimeoutUnit = TimeoutUnit.HOUR
@field_validator("inputs")
@classmethod
def _validate_inputs(cls, inputs: list[FormInput]) -> list[FormInput]:
seen_names: set[str] = set()
for form_input in inputs:
name = form_input.output_variable_name
if name in seen_names:
raise ValueError(f"duplicated output_variable_name '{name}' in inputs")
seen_names.add(name)
return inputs
@field_validator("user_actions")
@classmethod
def _validate_user_actions(cls, user_actions: list[UserAction]) -> list[UserAction]:
seen_ids: set[str] = set()
for action in user_actions:
action_id = action.id
if action_id in seen_ids:
raise ValueError(f"duplicated user action id '{action_id}'")
seen_ids.add(action_id)
return user_actions
def is_webapp_enabled(self) -> bool:
for dm in self.delivery_methods:
if not dm.enabled:
continue
if dm.type == DeliveryMethodType.WEBAPP:
return True
return False
def expiration_time(self, start_time: datetime) -> datetime:
if self.timeout_unit == TimeoutUnit.HOUR:
return start_time + timedelta(hours=self.timeout)
elif self.timeout_unit == TimeoutUnit.DAY:
return start_time + timedelta(days=self.timeout)
else:
raise AssertionError("unknown timeout unit.")
def outputs_field_names(self) -> Sequence[str]:
field_names = []
for match in _OUTPUT_VARIABLE_PATTERN.finditer(self.form_content):
field_names.append(match.group("field_name"))
return field_names
def extract_variable_selector_to_variable_mapping(self, node_id: str) -> Mapping[str, Sequence[str]]:
variable_selectors = []
variable_template_parser = VariableTemplateParser(template=self.form_content)
variable_selectors.extend(variable_template_parser.extract_variable_selectors())
variable_mappings = {}
for variable_selector in variable_selectors:
qualified_variable_mapping_key = f"{node_id}.{variable_selector.variable}"
variable_mappings[qualified_variable_mapping_key] = variable_selector.value_selector
for input in self.inputs:
placeholder = input.placeholder
if placeholder is None:
continue
if placeholder.type == PlaceholderType.CONSTANT:
continue
placeholder_key = ".".join(placeholder.selector)
qualified_variable_mapping_key = f"{node_id}.#{placeholder_key}#"
variable_mappings[qualified_variable_mapping_key] = placeholder.selector
return variable_mappings
class HumanInputRequired(BaseModel):
"""Event data for human input required."""
@ -176,72 +279,11 @@ class HumanInputRequired(BaseModel):
web_app_form_token: Optional[str] = None
class WorkflowSuspended(BaseModel):
"""Event data for workflow suspended."""
suspended_at_node_ids: list[str]
class PauseTypeHumanInput(BaseModel):
"""Pause type for human input."""
type: Literal["human_input"]
form_id: str
class PauseTypeBreakpoint(BaseModel):
"""Pause type for breakpoint (debugging)."""
type: Literal["breakpoint"]
PauseType = Union[PauseTypeHumanInput, PauseTypeBreakpoint]
class PausedNode(BaseModel):
"""Information about a paused node."""
node_id: str
node_title: str
pause_type: PauseType
class WorkflowPauseDetails(BaseModel):
"""Details about workflow pause."""
paused_at: str # ISO datetime
paused_nodes: list[PausedNode]
class FormSubmissionRequest(BaseModel):
"""Form submission request data."""
inputs: dict[str, str] # mapping of output_variable_name to user input
action: str # UserAction id
class FormGetResponse(BaseModel):
"""Response for form get API."""
site: Optional[dict[str, Any]] = None # Site information for webapp
class FormDefinition(BaseModel):
form_content: str
inputs: list[FormInput]
inputs: list[FormInput] = Field(default_factory=list)
user_actions: list[UserAction] = Field(default_factory=list)
rendered_content: str
class FormSubmissionResponse(BaseModel):
"""Response for successful form submission."""
pass # Empty response for success
class FormErrorResponse(BaseModel):
"""Response for form submission errors."""
error_code: str
description: str
class ResumeWaitResponse(BaseModel):
"""Response for resume wait API."""
status: Literal["paused", "running", "ended"]
timeout: int
timeout_unit: TimeoutUnit

View File

@ -1,13 +1,27 @@
from collections.abc import Mapping
import dataclasses
import logging
from collections.abc import Generator, Mapping, Sequence
from typing import Any
from core.workflow.entities.pause_reason import HumanInputRequired
from core.workflow.enums import NodeExecutionType, NodeType, WorkflowNodeExecutionStatus
from core.workflow.node_events import NodeRunResult, PauseRequestedEvent
from core.workflow.node_events.base import NodeEventBase
from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig
from core.workflow.nodes.base.node import Node
from core.workflow.repositories.human_input_form_repository import FormCreateParams, HumanInputFormRepository
from .entities import HumanInputNodeData
_SELECTED_BRANCH_KEY = "selected_branch"
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class _FormSubmissionResult:
action_id: str
class HumanInputNode(Node[HumanInputNodeData]):
node_type = NodeType.HUMAN_INPUT
@ -17,7 +31,7 @@ class HumanInputNode(Node[HumanInputNodeData]):
"edge_source_handle",
"edgeSourceHandle",
"source_handle",
"selected_branch",
_SELECTED_BRANCH_KEY,
"selectedBranch",
"branch",
"branch_id",
@ -25,43 +39,12 @@ class HumanInputNode(Node[HumanInputNodeData]):
"handle",
)
_node_data: HumanInputNodeData
@classmethod
def version(cls) -> str:
return "1"
def _run(self): # type: ignore[override]
if self._is_completion_ready():
branch_handle = self._resolve_branch_selection()
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
outputs={},
edge_source_handle=branch_handle or "source",
)
return self._pause_generator()
def _pause_generator(self):
# TODO(QuantumGhost): yield a real form id.
yield PauseRequestedEvent(reason=HumanInputRequired(form_id="test_form_id", node_id=self.id))
def _is_completion_ready(self) -> bool:
"""Determine whether all required inputs are satisfied."""
if not self.node_data.required_variables:
return False
variable_pool = self.graph_runtime_state.variable_pool
for selector_str in self.node_data.required_variables:
parts = selector_str.split(".")
if len(parts) != 2:
return False
segment = variable_pool.get(parts)
if segment is None:
return False
return True
def _resolve_branch_selection(self) -> str | None:
"""Determine the branch handle selected by human input if available."""
@ -108,3 +91,106 @@ class HumanInputNode(Node[HumanInputNodeData]):
return candidate
return None
def _create_form_repository(self) -> HumanInputFormRepository:
pass
@staticmethod
def _pause_generator(event: PauseRequestedEvent) -> Generator[NodeEventBase, None, None]:
yield event
@property
def _workflow_execution_id(self) -> str:
workflow_exec_id = self.graph_runtime_state.variable_pool.system_variables.workflow_execution_id
assert workflow_exec_id is not None
return workflow_exec_id
def _run(self) -> NodeRunResult | Generator[NodeEventBase, None, None]:
"""
Execute the human input node.
This method will:
1. Generate a unique form ID
2. Create form content with variable substitution
3. Create form in database
4. Send form via configured delivery methods
5. Suspend workflow execution
6. Wait for form submission to resume
"""
repo = self._create_form_repository()
submission_result = repo.get_form_submission(self._workflow_execution_id, self.app_id)
if submission_result:
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
outputs={
"action_id": submission_result.selected_action_id,
},
edge_source_handle=submission_result.selected_action_id,
)
try:
repo = self._create_form_repository()
params = FormCreateParams(
workflow_execution_id=self._workflow_execution_id,
node_id=self.id,
form_config=self._node_data,
rendered_content=self._render_form_content(),
)
result = repo.create_form(params)
# Create human input required event
required_event = HumanInputRequired(
form_id=result.id,
form_content=self._node_data.form_content,
inputs=self._node_data.inputs,
web_app_form_token=result.web_app_token,
)
pause_requested_event = PauseRequestedEvent(reason=required_event)
# Create workflow suspended event
logger.info(
"Human Input node suspended workflow for form. workflow_run_id=%s, node_id=%s, form_id=%s",
self.graph_runtime_state.variable_pool.system_variables.workflow_execution_id,
self.id,
result.id,
)
except Exception as e:
logger.exception("Human Input node failed to execute, node_id=%s", self.id)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
error=str(e),
error_type="HumanInputNodeError",
)
return self._pause_generator(pause_requested_event)
def _render_form_content(self) -> str:
"""
Process form content by substituting variables.
This method should:
1. Parse the form_content markdown
2. Substitute {{#node_name.var_name#}} with actual values
3. Keep {{#$output.field_name#}} placeholders for form inputs
"""
rendered_form_content = self.graph_runtime_state.variable_pool.convert_template(
self._node_data.form_content,
)
return rendered_form_content.markdown
@classmethod
def _extract_variable_selector_to_variable_mapping(
cls,
*,
graph_config: Mapping[str, Any],
node_id: str,
node_data: Mapping[str, Any],
) -> Mapping[str, Sequence[str]]:
"""
Extract variable selectors referenced in form content and input placeholders.
This method should parse:
1. Variables referenced in form_content ({{#node_name.var_name#}})
2. Variables referenced in input placeholders
"""
validated_node_data = HumanInputNodeData.model_validate(node_data)
return validated_node_data.extract_variable_selector_to_variable_mapping(node_id)

View File

@ -1,259 +0,0 @@
"""
Human Input node implementation.
"""
import json
import logging
import uuid
from collections.abc import Generator, Mapping, Sequence
from typing import Any, Optional, Union
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.graph_engine.entities.event import InNodeEvent
from core.workflow.nodes.base import BaseNode
from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig
from core.workflow.nodes.enums import ErrorStrategy, NodeType
from core.workflow.nodes.event import NodeEvent
from core.workflow.nodes.human_input.entities import (
HumanInputNodeData,
HumanInputRequired,
WorkflowSuspended,
)
from extensions.ext_database import db
from services.human_input_form_service import HumanInputFormService
logger = logging.getLogger(__name__)
class HumanInputNode(BaseNode):
"""
Human Input Node implementation.
This node pauses workflow execution and waits for human input through
configured delivery methods (webapp or email). The workflow resumes
once the form is submitted.
"""
_node_type: NodeType = NodeType.HUMAN_INPUT
_node_data_cls = HumanInputNodeData
node_data: HumanInputNodeData
def init_node_data(self, data: Mapping[str, Any]) -> None:
"""Initialize node data from configuration."""
self.node_data = self._node_data_cls.model_validate(data)
def _run(self) -> NodeRunResult | Generator[Union[NodeEvent, InNodeEvent], None, None]:
"""
Execute the human input node.
This method will:
1. Generate a unique form ID
2. Create form content with variable substitution
3. Create form in database
4. Send form via configured delivery methods
5. Suspend workflow execution
6. Wait for form submission to resume
"""
try:
# Generate unique form ID
form_id = str(uuid.uuid4())
# Create form content with variable substitution
form_content = self._process_form_content()
# Generate webapp token if webapp delivery is enabled
web_app_form_token = None
webapp_enabled = any(dm.enabled and dm.type.value == "webapp" for dm in self.node_data.delivery_methods)
if webapp_enabled:
web_app_form_token = str(uuid.uuid4()).replace("-", "")
# Create form definition for database storage
form_definition = {
"node_id": self.node_id,
"title": self.node_data.title,
"inputs": [inp.model_dump() for inp in self.node_data.inputs],
"user_actions": [action.model_dump() for action in self.node_data.user_actions],
"timeout": self.node_data.timeout,
"timeout_unit": self.node_data.timeout_unit.value,
"delivery_methods": [dm.model_dump() for dm in self.node_data.delivery_methods],
}
# Create form in database
service = HumanInputFormService(db.session())
service.create_form(
form_id=form_id,
workflow_run_id=self.graph_runtime_state.workflow_run_id,
tenant_id=self.graph_init_params.tenant_id,
app_id=self.graph_init_params.app_id,
form_definition=json.dumps(form_definition),
rendered_content=form_content,
web_app_token=web_app_form_token,
)
# Create human input required event
human_input_event = HumanInputRequired(
form_id=form_id,
node_id=self.node_id,
form_content=form_content,
inputs=self.node_data.inputs,
web_app_form_token=web_app_form_token,
)
# Create workflow suspended event
suspended_event = WorkflowSuspended(suspended_at_node_ids=[self.node_id])
logger.info(f"Human Input node {self.node_id} suspended workflow for form {form_id}")
# Return suspension result
# The workflow engine should handle the suspension and resume logic
return NodeRunResult(
status=WorkflowNodeExecutionStatus.RUNNING, # Node is still running, waiting for input
inputs={},
outputs={},
metadata={
"form_id": form_id,
"web_app_form_token": web_app_form_token,
"human_input_event": human_input_event.model_dump(),
"suspended_event": suspended_event.model_dump(),
"suspended": True, # Flag to indicate this node caused suspension
},
)
except Exception as e:
logger.exception(f"Human Input node {self.node_id} failed to execute")
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
error=str(e),
error_type="HumanInputNodeError",
)
def _process_form_content(self) -> str:
"""
Process form content by substituting variables.
This method should:
1. Parse the form_content markdown
2. Substitute {{#node_name.var_name#}} with actual values
3. Keep {{#$output.field_name#}} placeholders for form inputs
"""
# TODO: Implement variable substitution logic
# For now, return the raw form content
# This should integrate with the existing variable template parser
return self.node_data.form_content
@classmethod
def _extract_variable_selector_to_variable_mapping(
cls,
*,
graph_config: Mapping[str, Any],
node_id: str,
node_data: Mapping[str, Any],
) -> Mapping[str, Sequence[str]]:
"""
Extract variable selectors referenced in form content and input placeholders.
This method should parse:
1. Variables referenced in form_content ({{#node_name.var_name#}})
2. Variables referenced in input placeholders
"""
# TODO: Implement variable extraction logic
# This should parse the form_content and placeholder configurations
# to extract all referenced variables
return {}
@classmethod
def get_default_config(cls, filters: Optional[dict] = None) -> dict:
"""Get default configuration for human input node."""
return {
"type": "human_input",
"config": {
"delivery_methods": [{"type": "webapp", "enabled": True, "config": {}}],
"form_content": "# Human Input\n\nPlease provide your input:\n\n{{#$output.input#}}",
"inputs": [
{
"type": "text-input",
"output_variable_name": "input",
"placeholder": {"type": "constant", "value": "Enter your response here..."},
}
],
"user_actions": [{"id": "submit", "title": "Submit", "button_style": "primary"}],
"timeout": 24,
"timeout_unit": "hour",
},
}
@classmethod
def version(cls) -> str:
"""Return the version of the human input node."""
return "1"
def _get_error_strategy(self) -> Optional[ErrorStrategy]:
"""Get the error strategy for this node."""
return self.node_data.error_strategy
def _get_retry_config(self) -> RetryConfig:
"""Get the retry configuration for this node."""
return self.node_data.retry_config
def _get_title(self) -> str:
"""Get the node title."""
return self.node_data.title
def _get_description(self) -> Optional[str]:
"""Get the node description."""
return self.node_data.desc
def _get_default_value_dict(self) -> dict[str, Any]:
"""Get the default values dictionary for this node."""
return self.node_data.default_value_dict
def get_base_node_data(self) -> BaseNodeData:
"""Get the BaseNodeData object for this node."""
return self.node_data
def resume_from_human_input(self, form_submission_data: dict[str, Any]) -> NodeRunResult:
"""
Resume node execution after human input form is submitted.
Args:
form_submission_data: Dict containing:
- inputs: Dict of input field values
- action: The user action taken
Returns:
NodeRunResult with the form inputs as outputs
"""
try:
inputs = form_submission_data.get("inputs", {})
action = form_submission_data.get("action", "")
# Create output dictionary with form inputs
outputs = {}
for input_field in self.node_data.inputs:
field_name = input_field.output_variable_name
if field_name in inputs:
outputs[field_name] = inputs[field_name]
# Add the action to outputs
outputs["_action"] = action
logger.info(f"Human Input node {self.node_id} resumed with action {action}")
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs={},
outputs=outputs,
metadata={
"form_submitted": True,
"submitted_action": action,
},
)
except Exception as e:
logger.exception(f"Human Input node {self.node_id} failed to resume")
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
error=str(e),
error_type="HumanInputResumeError",
)