# 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)