gateway/modules/workflows/automation2/executors/actionNodeExecutor.py
2026-04-13 01:37:29 +02:00

363 lines
15 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# Action node executor - maps ai.*, email.*, sharepoint.*, clickup.*, file.*, trustee.* to method actions.
#
# Typed Port System: no heuristic merging. Uses INPUT_EXTRACTORS for wire-handover,
# DataRef for explicit parameter mapping, and _normalizeToSchema for output normalization.
import json
import logging
import re
from typing import Dict, Any, List, Optional
from modules.features.graphicalEditor.portTypes import (
INPUT_EXTRACTORS,
_normalizeToSchema,
_normalizeError,
_unwrapTransit,
)
logger = logging.getLogger(__name__)
_USER_CONNECTION_ID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
def _isUserConnectionId(val: Any) -> bool:
if val is None or isinstance(val, (dict, list)):
return False
s = str(val).strip()
return bool(_USER_CONNECTION_ID_RE.match(s))
def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]:
"""Get node definition by type id."""
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
for node in STATIC_NODE_TYPES:
if node.get("id") == nodeType:
return node
return None
def _resolveConnectionIdToReference(chatService, connectionId: str, services=None) -> Optional[str]:
"""
Resolve connectionId (UserConnection.id) to connectionReference format.
connectionReference format: connection:{authority}:{externalUsername}
"""
if not connectionId:
return None
if isinstance(connectionId, str) and connectionId.startswith("connection:"):
return connectionId
if chatService:
try:
connections = chatService.getUserConnections()
for c in connections or []:
conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {})
if str(conn.get("id")) == str(connectionId):
authority = conn.get("authority")
if hasattr(authority, "value"):
authority = authority.value
username = conn.get("externalUsername", "")
return f"connection:{authority}:{username}"
except Exception as e:
logger.debug("_resolveConnectionIdToReference chatService: %s", e)
app = getattr(services, "interfaceDbApp", None) if services else None
if app and hasattr(app, "getUserConnectionById"):
try:
conn = app.getUserConnectionById(str(connectionId))
if conn:
authority = getattr(conn, "authority", None)
if hasattr(authority, "value"):
authority = authority.value
else:
authority = str(authority) if authority else "outlook"
username = getattr(conn, "externalUsername", "") or ""
return f"connection:{authority}:{username}"
except Exception as e:
logger.debug("_resolveConnectionIdToReference getUserConnectionById: %s", e)
return None
def _buildEmailFilter(fromAddress: str = None, subjectContains: str = None, hasAttachment: bool = None) -> str:
"""Build Microsoft Graph API $filter string."""
parts = []
if fromAddress and str(fromAddress).strip():
safe = str(fromAddress).strip().replace("'", "''")
parts.append(f"from/emailAddress/address eq '{safe}'")
if subjectContains and str(subjectContains).strip():
safe = str(subjectContains).strip().replace("'", "''")
parts.append(f"contains(subject,'{safe}')")
if hasAttachment is True:
parts.append("hasAttachments eq true")
return " and ".join(parts) if parts else ""
def _buildSearchQuery(
query: str = None, fromAddress: str = None, toAddress: str = None,
subjectContains: str = None, bodyContains: str = None,
hasAttachment: bool = None, filterStr: str = None,
) -> str:
"""Build Microsoft Graph $search query from discrete params."""
if filterStr and str(filterStr).strip():
return str(filterStr).strip()
parts = []
if query and str(query).strip():
parts.append(str(query).strip())
if fromAddress and str(fromAddress).strip():
parts.append(f'from:{str(fromAddress).strip().replace(chr(34), "")}')
if toAddress and str(toAddress).strip():
parts.append(f'to:{str(toAddress).strip().replace(chr(34), "")}')
if subjectContains and str(subjectContains).strip():
parts.append(f'subject:{str(subjectContains).strip().replace(chr(34), "")}')
if bodyContains and str(bodyContains).strip():
parts.append(f'body:{str(bodyContains).strip().replace(chr(34), "")}')
if hasAttachment is True:
parts.append("hasattachments:true")
return " ".join(parts) if parts else "*"
def _resolveConnectionParam(params: Dict, chatService, services) -> None:
"""Resolve connectionReference if it looks like a UUID (UserConnection.id)."""
connRef = params.get("connectionReference")
if connRef and _isUserConnectionId(connRef):
resolved = _resolveConnectionIdToReference(chatService, connRef, services)
if resolved:
params["connectionReference"] = resolved
def _applyEmailCheckFilter(params: Dict) -> None:
"""Build filter from discrete email params for email.checkEmail."""
built = _buildEmailFilter(
fromAddress=params.get("fromAddress"),
subjectContains=params.get("subjectContains"),
hasAttachment=params.get("hasAttachment"),
)
rawFilter = (params.get("filter") or "").strip()
params["filter"] = built if built else (rawFilter if rawFilter else None)
for k in ("fromAddress", "subjectContains", "hasAttachment"):
params.pop(k, None)
def _applyEmailSearchQuery(params: Dict) -> None:
"""Build query from discrete email params for email.searchEmail."""
built = _buildSearchQuery(
query=params.get("query"),
fromAddress=params.get("fromAddress"),
toAddress=params.get("toAddress"),
subjectContains=params.get("subjectContains"),
bodyContains=params.get("bodyContains"),
hasAttachment=params.get("hasAttachment"),
filterStr=params.get("filter"),
)
params["query"] = built
for k in ("fromAddress", "toAddress", "subjectContains", "bodyContains", "hasAttachment", "filter"):
params.pop(k, None)
def _wireHandover(nodeDef: Dict, inputSources: Dict, nodeOutputs: Dict, params: Dict) -> None:
"""Apply wire-handover: extract fields from upstream using INPUT_EXTRACTORS."""
if 0 not in inputSources:
return
srcId, _ = inputSources[0]
upstream = nodeOutputs.get(srcId)
if not upstream or not isinstance(upstream, dict):
return
data = _unwrapTransit(upstream)
if not isinstance(data, dict):
return
inputPorts = nodeDef.get("inputPorts", {})
port0 = inputPorts.get(0, {})
accepts = port0.get("accepts", [])
for schemaName in accepts:
if schemaName == "Transit":
continue
extractor = INPUT_EXTRACTORS.get(schemaName)
if extractor:
extracted = extractor(data)
if extracted:
for k, v in extracted.items():
params.setdefault(k, v)
return
def _getOutputSchemaName(nodeDef: Dict) -> str:
"""Get the output schema name from the node definition."""
outputPorts = nodeDef.get("outputPorts", {})
port0 = outputPorts.get(0, {})
return port0.get("schema", "ActionResult")
class ActionNodeExecutor:
"""Execute action nodes by mapping to method actions via ActionExecutor."""
def __init__(self, services: Any):
self.services = services
async def execute(
self,
node: Dict[str, Any],
context: Dict[str, Any],
) -> Any:
from modules.features.graphicalEditor.nodeRegistry import getNodeTypeToMethodAction
from modules.workflows.automation2.graphUtils import resolveParameterReferences
from modules.workflows.processing.core.actionExecutor import ActionExecutor
nodeType = node.get("type", "")
nodeId = node.get("id", "")
logger.info("ActionNodeExecutor node %s type=%s", nodeId, nodeType)
mapping = getNodeTypeToMethodAction()
methodAction = mapping.get(nodeType)
if not methodAction:
logger.debug("ActionNodeExecutor node %s not in mapping -> None", nodeId)
return None
methodName, actionName = methodAction
nodeDef = _getNodeDefinition(nodeType) or {}
outputSchema = _getOutputSchemaName(nodeDef)
# 1. Resolve parameters (DataRef, SystemVar, Static)
params = dict(node.get("parameters") or {})
resolvedParams = resolveParameterReferences(params, context.get("nodeOutputs", {}))
# 2. Wire-handover via extractors (fills missing params from upstream)
inputSources = context.get("inputSources", {}).get(nodeId, {})
_wireHandover(nodeDef, inputSources, context.get("nodeOutputs", {}), resolvedParams)
# 3. Apply defaults from parameter definitions
for pDef in nodeDef.get("parameters", []):
pName = pDef.get("name")
if pName and pName not in resolvedParams and "default" in pDef:
resolvedParams[pName] = pDef["default"]
# 4. Resolve connectionReference
chatService = getattr(self.services, "chat", None)
_resolveConnectionParam(resolvedParams, chatService, self.services)
# 5. Node-type-specific param transformations
if nodeType == "email.checkEmail":
_applyEmailCheckFilter(resolvedParams)
elif nodeType == "email.searchEmail":
_applyEmailSearchQuery(resolvedParams)
elif nodeType == "clickup.updateTask":
from modules.workflows.automation2.clickupTaskUpdateMerge import merge_clickup_task_update_entries
merge_clickup_task_update_entries(resolvedParams)
# 6. email.checkEmail pause for email wait
if nodeType == "email.checkEmail":
runId = context.get("_runId")
workflowId = context.get("workflowId")
connRef = resolvedParams.get("connectionReference")
if runId and workflowId and connRef:
from modules.workflows.automation2.executors import PauseForEmailWaitError
waitConfig = {
"connectionReference": connRef,
"folder": resolvedParams.get("folder", "Inbox"),
"limit": min(int(resolvedParams.get("limit") or 10), 50),
"filter": resolvedParams.get("filter"),
}
raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig)
# 7. AI nodes: simpleMode by default
if nodeType == "ai.prompt" and "simpleMode" not in resolvedParams:
resolvedParams["simpleMode"] = True
# 8. Build context for email.draftEmail from subject + body
if nodeType == "email.draftEmail":
subject = resolvedParams.get("subject", "")
body = resolvedParams.get("body", "")
if subject or body:
contextParts = []
if subject:
contextParts.append(f"Subject: {subject}")
if body:
contextParts.append(f"Body:\n{body}")
resolvedParams["context"] = "\n\n".join(contextParts)
resolvedParams.pop("subject", None)
resolvedParams.pop("body", None)
# 9. file.create: build context from upstream
if nodeType == "file.create" and "context" not in resolvedParams:
if 0 in inputSources:
srcId, _ = inputSources[0]
upstream = context.get("nodeOutputs", {}).get(srcId)
if upstream and isinstance(upstream, dict):
data = _unwrapTransit(upstream)
ctx = ""
if isinstance(data, dict):
ctx = data.get("context") or data.get("response") or data.get("text") or ""
if ctx:
resolvedParams["context"] = ctx
# 10. Pass upstream documents as documentList if available
# Use truthiness check: empty values ([], "", None) from static graph params
# must not block automatic upstream population via wire connections.
if not resolvedParams.get("documentList") and 0 in inputSources:
srcId, _ = inputSources[0]
upstream = context.get("nodeOutputs", {}).get(srcId)
if upstream and isinstance(upstream, dict):
data = _unwrapTransit(upstream)
if isinstance(data, dict):
docs = data.get("documents") or data.get("documentList")
if docs:
resolvedParams["documentList"] = docs
# 11. Execute action
logger.info("ActionNodeExecutor node %s calling %s.%s", nodeId, methodName, actionName)
try:
executor = ActionExecutor(self.services)
result = await executor.executeAction(methodName, actionName, resolvedParams)
except Exception as e:
logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e)
return _normalizeError(e, outputSchema)
# 12. Build normalized output
docsList = [d.model_dump() if hasattr(d, "model_dump") else d for d in (result.documents or [])]
extractedContext = ""
if result.documents:
doc = result.documents[0]
raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None)
if raw:
extractedContext = raw.decode("utf-8", errors="replace").strip() if isinstance(raw, bytes) else str(raw).strip()
promptText = str(resolvedParams.get("aiPrompt") or resolvedParams.get("prompt") or "").strip()
out = {
"success": result.success,
"error": result.error,
"documents": docsList,
"documentList": docsList,
"data": result.model_dump() if hasattr(result, "model_dump") else {"success": result.success, "error": result.error},
}
if nodeType.startswith("ai."):
out["prompt"] = promptText
out["response"] = extractedContext
out["context"] = f"{promptText}\n\n{extractedContext}" if promptText and extractedContext else (extractedContext or promptText)
# Structured output
if extractedContext:
try:
parsed = json.loads(extractedContext)
if isinstance(parsed, dict):
out["responseData"] = parsed
except (json.JSONDecodeError, TypeError):
pass
if nodeType.startswith("clickup.") and result.success and docsList:
try:
d0 = docsList[0] if isinstance(docsList[0], dict) else {}
raw = d0.get("documentData")
if isinstance(raw, str) and raw.strip():
parsed = json.loads(raw)
if isinstance(parsed, dict) and parsed.get("id") is not None:
out["taskId"] = str(parsed["id"])
out["task"] = parsed
except (json.JSONDecodeError, TypeError, ValueError):
pass
return _normalizeToSchema(out, outputSchema)