Files
dify/api/services/human_input_v2/node_data_migration.py
T
8cbb6c0130 feat: HITL IM integration (#44864)
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-10-30 02:43:31 +00:00

199 lines
7.4 KiB
Python

"""Application orchestration for side-effect-free Human Input node migration."""
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Protocol
from core.human_input_v2.shared import TenantId
from core.workflow.human_input_adapter import EmailDeliveryMethod, MemberRecipient
from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull
from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData
from core.workflow.nodes.human_input_v2.migration import (
LegacyDeliveryParseIssue,
LegacyNodeDataPreflight,
MemberEmailSnapshot,
MigrationBlockerCode,
NodeMigrationConversion,
convert_legacy_human_input_node_data,
)
class WorkspaceMemberEmailLookup(Protocol):
"""Read the currently usable Account Emails for one workspace scope."""
def find_member_emails(self, tenant_id: TenantId, account_ids: Sequence[str]) -> MemberEmailSnapshot: ...
type NodeDataConverter = Callable[
[HumanInputNodeDataFull, MemberEmailSnapshot],
NodeMigrationConversion,
]
@dataclass(frozen=True, slots=True)
class MigrationNode:
node_id: str
node_data: HumanInputNodeDataFull
method_positions: tuple[int, ...] = ()
recipient_positions: tuple[tuple[int, ...], ...] = ()
preflight_issues: tuple[LegacyDeliveryParseIssue, ...] = ()
@classmethod
def from_preflight(cls, node_id: str, preflight: LegacyNodeDataPreflight) -> MigrationNode:
return cls(
node_id=node_id,
node_data=preflight.node_data,
method_positions=preflight.method_positions,
recipient_positions=preflight.recipient_positions,
preflight_issues=preflight.issues,
)
@dataclass(frozen=True, slots=True)
class MigratedNode:
node_id: str
node_data: HumanInputNodeData
@dataclass(frozen=True, slots=True)
class NodeDataMigrationBlocker:
node_id: str
node_title: str
code: MigrationBlockerCode
method_id: str | None = None
value: str | None = None
@dataclass(frozen=True, slots=True)
class NodeDataMigrationSuccess:
data: tuple[MigratedNode, ...]
@dataclass(frozen=True, slots=True)
class NodeDataMigrationFailure:
blockers: tuple[NodeDataMigrationBlocker, ...]
type NodeDataMigrationOutcome = NodeDataMigrationSuccess | NodeDataMigrationFailure
@dataclass(frozen=True, slots=True)
class _PositionedMigrationBlocker:
blocker: NodeDataMigrationBlocker
postflight: bool
method_position: int
recipient_position: int
ordinal: int
class HumanInputNodeDataMigrationService:
"""Create one member snapshot and deterministically preflight a whole batch."""
def __init__(
self,
*,
member_email_lookup: WorkspaceMemberEmailLookup,
converter: NodeDataConverter = convert_legacy_human_input_node_data,
) -> None:
self._member_email_lookup = member_email_lookup
self._converter = converter
def migrate(self, *, tenant_id: TenantId, nodes: Sequence[MigrationNode]) -> NodeDataMigrationOutcome:
member_ids = self._collect_member_ids(nodes)
member_email_snapshot = self._member_email_lookup.find_member_emails(tenant_id, member_ids)
migrated_nodes: list[MigratedNode] = []
blockers: list[NodeDataMigrationBlocker] = []
for node in nodes:
conversion = self._converter(node.node_data, member_email_snapshot)
blockers.extend(self._ordered_node_blockers(node, conversion))
if conversion.node_data is not None:
migrated_nodes.append(MigratedNode(node.node_id, conversion.node_data))
if blockers:
return NodeDataMigrationFailure(tuple(blockers))
return NodeDataMigrationSuccess(tuple(migrated_nodes))
@staticmethod
def _ordered_node_blockers(
node: MigrationNode,
conversion: NodeMigrationConversion,
) -> tuple[NodeDataMigrationBlocker, ...]:
method_positions = node.method_positions or tuple(range(len(node.node_data.delivery_methods)))
recipient_positions = node.recipient_positions or tuple(
tuple(range(len(method.config.recipients.items))) if isinstance(method, EmailDeliveryMethod) else ()
for method in node.node_data.delivery_methods
)
positioned_blockers: list[_PositionedMigrationBlocker] = []
ordinal = 0
for issue in node.preflight_issues:
positioned_blockers.append(
_PositionedMigrationBlocker(
blocker=NodeDataMigrationBlocker(
node_id=node.node_id,
node_title=node.node_data.title,
code=issue.code,
method_id=issue.method_id,
value=issue.value,
),
postflight=False,
method_position=issue.method_position if issue.method_position is not None else -1,
recipient_position=issue.recipient_position if issue.recipient_position is not None else -1,
ordinal=ordinal,
)
)
ordinal += 1
for blocker in conversion.blockers:
postflight = blocker.code in {
MigrationBlockerCode.CONFLICTING_EMAIL_TEMPLATES,
MigrationBlockerCode.MISSING_RECIPIENTS,
}
method_position = len(method_positions)
recipient_position = -1
if blocker.method_index is not None:
method_position = method_positions[blocker.method_index]
if blocker.recipient_index is not None:
recipient_position = recipient_positions[blocker.method_index][blocker.recipient_index]
positioned_blockers.append(
_PositionedMigrationBlocker(
blocker=NodeDataMigrationBlocker(
node_id=node.node_id,
node_title=node.node_data.title,
code=blocker.code,
method_id=blocker.method_id,
value=blocker.value,
),
postflight=postflight,
method_position=method_position,
recipient_position=recipient_position,
ordinal=ordinal,
)
)
ordinal += 1
positioned_blockers.sort(
key=lambda positioned: (
positioned.postflight,
positioned.method_position,
positioned.recipient_position,
positioned.ordinal,
)
)
return tuple(positioned.blocker for positioned in positioned_blockers)
@staticmethod
def _collect_member_ids(nodes: Sequence[MigrationNode]) -> tuple[str, ...]:
member_ids: list[str] = []
seen_member_ids: set[str] = set()
for node in nodes:
for method in node.node_data.delivery_methods:
if not method.enabled or not isinstance(method, EmailDeliveryMethod):
continue
for source in method.config.recipients.items:
if not isinstance(source, MemberRecipient) or source.reference_id in seen_member_ids:
continue
member_ids.append(source.reference_id)
seen_member_ids.add(source.reference_id)
return tuple(member_ids)