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

@ -41,7 +41,7 @@ class DefaultFieldsMixin:
)
updated_at: Mapped[datetime] = mapped_column(
__name_pos=DateTime,
DateTime,
nullable=False,
default=naive_utc_now,
server_default=func.current_timestamp(),

View File

@ -1,28 +1,21 @@
from datetime import datetime
from enum import StrEnum
from typing import Annotated, Any, ClassVar, Literal, Self, final
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from pydantic import BaseModel, Field
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.workflow.nodes.human_input.entities import (
DeliveryMethodType,
EmailRecipientType,
HumanInputFormStatus,
)
from libs.helper import generate_string
from .base import Base, ModelMixin
from .base import Base, DefaultFieldsMixin
from .types import EnumText, StringUUID
class HumanInputFormStatus(StrEnum):
WAITING = "waiting"
EXPIRED = "expired"
SUBMITTED = "submitted"
TIMEOUT = "timeout"
class HumanInputSubmissionType(StrEnum):
web_form = "web_form"
web_app = "web_app"
email = "email"
_token_length = 22
# A 32-character string can store a base64-encoded value with 192 bits of entropy
# or a base62-encoded value with over 180 bits of entropy, providing sufficient
@ -31,36 +24,18 @@ _token_field_length = 32
_email_field_length = 330
def _generate_token():
def _generate_token() -> str:
return generate_string(_token_length)
class HumanInputForm(ModelMixin, Base):
class HumanInputForm(DefaultFieldsMixin, Base):
__tablename__ = "human_input_forms"
# `tenant_id` identifies the tenant associated with this suspension,
# corresponding to the `id` field in the `Tenant` model.
tenant_id: Mapped[str] = mapped_column(
StringUUID,
nullable=False,
)
# `app_id` represents the application identifier associated with this state.
# It corresponds to the `id` field in the `App` model.
#
# While this field is technically redundant (as the corresponding app can be
# determined by querying the `Workflow`), it is retained to simplify data
# cleanup and management processes.
app_id: Mapped[str] = mapped_column(
StringUUID,
nullable=False,
)
workflow_run_id: Mapped[str] = mapped_column(
StringUUID,
nullable=False,
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_run_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
# The human input node the current form corresponds to.
node_id: Mapped[str] = mapped_column(sa.String(60), nullable=False)
form_definition: Mapped[str] = mapped_column(sa.Text, nullable=False)
rendered_content: Mapped[str] = mapped_column(sa.Text, nullable=False)
status: Mapped[HumanInputFormStatus] = mapped_column(
@ -69,56 +44,137 @@ class HumanInputForm(ModelMixin, Base):
default=HumanInputFormStatus.WAITING,
)
web_app_token: Mapped[str] = mapped_column(
expiration_time: Mapped[datetime] = mapped_column(
sa.DateTime,
nullable=False,
)
# Submission-related fields (nullable until a submission happens).
selected_action_id: Mapped[str | None] = mapped_column(sa.String(200), nullable=True)
submitted_data: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True)
submission_user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
submission_end_user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
completed_by_recipient_id: Mapped[str | None] = mapped_column(
StringUUID,
sa.ForeignKey("human_input_recipients.id"),
nullable=True,
)
deliveries: Mapped[list["HumanInputDelivery"]] = relationship(
"HumanInputDelivery",
back_populates="form",
lazy="raise",
)
completed_by_recipient: Mapped["HumanInputRecipient | None"] = relationship(
"HumanInputRecipient",
primaryjoin="HumanInputForm.completed_by_recipient_id == HumanInputRecipient.id",
lazy="raise",
viewonly=True,
)
class HumanInputDelivery(DefaultFieldsMixin, Base):
__tablename__ = "human_input_deliveries"
form_id: Mapped[str] = mapped_column(
StringUUID,
sa.ForeignKey("human_input_forms.id"),
nullable=False,
)
delivery_method_type: Mapped[DeliveryMethodType] = mapped_column(
EnumText(DeliveryMethodType),
nullable=False,
)
delivery_config_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
channel_payload: Mapped[None] = mapped_column(sa.Text, nullable=True)
form: Mapped[HumanInputForm] = relationship(
"HumanInputForm",
back_populates="deliveries",
lazy="raise",
)
recipients: Mapped[list["HumanInputRecipient"]] = relationship(
"HumanInputRecipient",
back_populates="delivery",
cascade="all, delete-orphan",
lazy="raise",
)
class RecipientType(StrEnum):
# EMAIL_MEMBER member means that the
EMAIL_MEMBER = "email_member"
EMAIL_EXTERNAL = "email_external"
WEBAPP = "webapp"
@final
class EmailMemberRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.EMAIL_MEMBER] = RecipientType.EMAIL_MEMBER
user_id: str
# The `email` field here is only used for mail sending.
email: str
@final
class EmailExternalRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.EMAIL_EXTERNAL] = RecipientType.EMAIL_EXTERNAL
email: str
@final
class WebAppRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.WEBAPP] = RecipientType.WEBAPP
RecipientPayload = Annotated[
EmailMemberRecipientPayload | EmailExternalRecipientPayload | WebAppRecipientPayload,
Field(discriminator="TYPE"),
]
class HumanInputRecipient(DefaultFieldsMixin, Base):
__tablename__ = "human_input_recipients"
form_id: Mapped[str] = mapped_column(
StringUUID,
nullable=False,
)
delivery_id: Mapped[str] = mapped_column(
StringUUID,
nullable=False,
)
recipient_type: Mapped["RecipientType"] = mapped_column(EnumText(RecipientType), nullable=False)
recipient_payload: Mapped[str] = mapped_column(sa.Text, nullable=False)
# Token primarily used for authenticated resume links (email, etc.).
access_token: Mapped[str | None] = mapped_column(
sa.VARCHAR(_token_field_length),
nullable=True,
default=_generate_token,
)
# The following fields are not null if the form is submitted.
# The inputs provided by the user when resuming the suspended workflow.
# These inputs are serialized as a JSON-formatted string (e.g., `{}`).
#
# This field is `NULL` if no inputs were submitted by the user.
submitted_data: Mapped[str] = mapped_column(sa.Text, nullable=True)
submitted_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=True)
submission_type: Mapped[HumanInputSubmissionType] = mapped_column(
EnumText(HumanInputSubmissionType),
nullable=True,
delivery: Mapped[HumanInputDelivery] = relationship(
"HumanInputDelivery",
back_populates="recipients",
lazy="raise",
)
# If the submission happens in dashboard (Studio for orchestrate the workflow, or
# Explore for using published apps), which requires user to login before submission.
# Then the `submission_user_id` records the user id
# of submitter, else `None`.
submission_user_id: Mapped[str] = mapped_column(StringUUID, nullable=True)
# If the submission happens in WebApp (which does not requires user to login before submission)
# Then the `submission_user_id` records the end_user_id of submitter, else `None`.
submission_end_user_id: Mapped[str] = mapped_column(StringUUID, nullable=True)
# IF the submitter receives a email and
submitter_email: Mapped[str] = mapped_column(sa.VARCHAR(_email_field_length), nullable=True)
# class HumanInputEmailDelivery(ModelMixin, Base):
# # form_id refers to `HumanInputForm.id`
# form_id: Mapped[str] = mapped_column(
# StringUUID,
# nullable=False,
# )
# # IF the submitter receives a email and
# email: Mapped[str] = mapped_column(__name_pos=sa.VARCHAR(_email_field_length), nullable=False)
# user_id: Mapped[str] = mapped_column(
# StringUUID,
# nullable=True,
# )
# email_link_token: Mapped[str] = mapped_column(
# sa.VARCHAR(_token_field_length),
# nullable=False,
# default=_generate_token,
# )
@classmethod
def new(
cls,
form_id: str,
delivery_id: str,
payload: RecipientPayload,
) -> Self:
recipient_model = cls(
form_id=form_id,
delivery_id=delivery_id,
recipient_type=payload.TYPE,
recipient_payload=payload.model_dump_json(),
access_token=_generate_token(),
)
return recipient_model

View File

@ -30,7 +30,7 @@ from core.workflow.constants import (
SYSTEM_VARIABLE_NODE_ID,
)
from core.workflow.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType, SchedulingPause
from core.workflow.enums import NodeType
from core.workflow.enums import NodeType, WorkflowExecutionStatus
from extensions.ext_storage import Storage
from factories.variable_factory import TypeMismatchError, build_segment_with_type
from libs.datetime_utils import naive_utc_now
@ -607,7 +607,10 @@ class WorkflowRun(Base):
version: Mapped[str] = mapped_column(String(255))
graph: Mapped[str | None] = mapped_column(LongText)
inputs: Mapped[str | None] = mapped_column(LongText)
status: Mapped[str] = mapped_column(String(255)) # running, succeeded, failed, stopped, partial-succeeded
status: Mapped[WorkflowExecutionStatus] = mapped_column(
EnumText(WorkflowExecutionStatus, length=255),
nullable=False,
)
outputs: Mapped[str | None] = mapped_column(LongText, default="{}")
error: Mapped[str | None] = mapped_column(LongText)
elapsed_time: Mapped[float] = mapped_column(sa.Float, nullable=False, server_default=sa.text("0"))