# Copyright (c) 2025 Patrick Motsch # Action node executor - maps ai.*, email.*, sharepoint.*, clickup.*, file.*, trustee.* to method actions. # # Typed Port System: explicit DataRefs / static parameters only (no runtime wire-handover). # ``materializeConnectionRefs`` (see pickNotPushMigration) may still rewrite empty connectionReference at run start. import json import logging import re from typing import Any, Dict, Optional from modules.features.graphicalEditor.portTypes import ( _normalizeError, normalizeToSchema, ) from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException as _SubscriptionInactiveException from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError as _BillingContextError 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 _buildConnectionRefDict(connRef: str, chatService, services) -> Optional[Dict[str, Any]]: """ Build {id, authority, label} for node outputs (no secrets). connRef may be UUID or logical connection:authority:user. """ if not connRef or not isinstance(connRef, str): return None original_ref = connRef.strip() ref = original_ref if _isUserConnectionId(ref): resolved = _resolveConnectionIdToReference(chatService, ref, services) if resolved: ref = resolved if not ref.startswith("connection:"): return None parts = ref.split(":", 2) authority = parts[1] if len(parts) > 1 else "" user = parts[2] if len(parts) > 2 else "" label = ref conn_id = "" if chatService: try: for c in chatService.getUserConnections() or []: conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {}) aid = conn.get("authority", "") if hasattr(aid, "value"): aid = aid.value un = conn.get("externalUsername", "") or conn.get("externalId", "") or "" logical = f"connection:{aid}:{un}" if logical == ref or str(conn.get("id")) == original_ref: conn_id = str(conn.get("id", "") or "") break except Exception as e: logger.debug("_buildConnectionRefDict: getUserConnections: %s", e) return {"id": conn_id, "authority": authority, "label": label or f"{authority}:{user}"} def _attachConnectionProvenance( out: Dict[str, Any], resolvedParams: Dict[str, Any], outputSchema: str, chatService, services, ) -> None: """Mutates out to include connection provenance for typed list/draft outputs.""" if out.get("connection"): return cref = resolvedParams.get("connectionReference") if not cref: return if outputSchema not in ("FileList", "DocumentList", "EmailList", "TaskList", "EmailDraft", "UdmDocument"): return payload = _buildConnectionRefDict(str(cref), chatService, services) if payload: out["connection"] = payload 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 _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 {}) logger.debug("ActionNodeExecutor node %s raw params keys=%s", nodeId, list(params.keys())) resolvedParams = resolveParameterReferences(params, context.get("nodeOutputs", {})) logger.debug("ActionNodeExecutor node %s resolved params keys=%s documentList_present=%s documentList_type=%s", nodeId, list(resolvedParams.keys()), "documentList" in resolvedParams, type(resolvedParams.get("documentList")).__name__) # 2. 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"] # 3. Resolve connectionReference chatService = getattr(self.services, "chat", None) _resolveConnectionParam(resolvedParams, chatService, self.services) # 4. 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) # 5. 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) # 6. AI nodes: normalize legacy "prompt" -> "aiPrompt" if nodeType == "ai.prompt": if "aiPrompt" not in resolvedParams and "prompt" in resolvedParams: resolvedParams["aiPrompt"] = resolvedParams.pop("prompt") # 7. 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) # 8. Execute action logger.info("ActionNodeExecutor node %s calling %s.%s with %d params", nodeId, methodName, actionName, len(resolvedParams)) try: executor = ActionExecutor(self.services) result = await executor.executeAction(methodName, actionName, resolvedParams) except (_SubscriptionInactiveException, _BillingContextError): raise except Exception as e: logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e) return _normalizeError(e, outputSchema) # 9. Persist generated documents as files and build JSON-safe output docsList = [] for d in (result.documents or []): dumped = d.model_dump() if hasattr(d, "model_dump") else dict(d) if isinstance(d, dict) else d rawData = getattr(d, "documentData", None) if hasattr(d, "documentData") else (dumped.get("documentData") if isinstance(dumped, dict) else None) if isinstance(dumped, dict) and isinstance(rawData, bytes) and len(rawData) > 0: try: from modules.interfaces.interfaceDbManagement import getInterface as _getMgmtInterface from modules.interfaces.interfaceDbApp import getInterface as _getAppInterface from modules.security.rootAccess import getRootUser _userId = context.get("userId") _mandateId = context.get("mandateId") _instanceId = context.get("instanceId") _owner = None if _userId: try: _umap = _getAppInterface(getRootUser()).getUsersByIds([str(_userId)]) _owner = _umap.get(str(_userId)) except Exception as _ue: logger.warning("Could not resolve workflow user for file persistence: %s", _ue) if _owner is None: _owner = getRootUser() logger.debug( "Persisting workflow document as root user (no resolved owner userId=%r)", _userId, ) _mgmt = _getMgmtInterface(_owner, mandateId=_mandateId, featureInstanceId=_instanceId) _docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin" _mimeType = dumped.get("mimeType") or "application/octet-stream" _fileItem = _mgmt.createFile(_docName, _mimeType, rawData) _mgmt.createFileData(_fileItem.id, rawData) dumped["fileId"] = _fileItem.id dumped["id"] = _fileItem.id dumped["fileName"] = _fileItem.fileName logger.info("Persisted workflow document %s as file %s", _docName, _fileItem.id) except Exception as _fe: logger.warning("Could not persist workflow document: %s", _fe) dumped["documentData"] = None dumped["_hasBinaryData"] = True docsList.append(dumped) # Clean DocumentList shape for document nodes (match file.create: documents + count, no AiResult fields) if outputSchema == "DocumentList" and nodeType in ("ai.generateDocument", "ai.convertDocument"): if not result.success: return _normalizeError( RuntimeError(str(result.error or "document action failed")), outputSchema, ) list_out: Dict[str, Any] = { "documents": docsList, "count": len(docsList), } _attachConnectionProvenance(list_out, resolvedParams, outputSchema, chatService, self.services) return normalizeToSchema(list_out, outputSchema) 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 isinstance(raw, bytes): try: extractedContext = raw.decode("utf-8").strip() except (UnicodeDecodeError, ValueError): extractedContext = "" elif raw: extractedContext = str(raw).strip() promptText = str(resolvedParams.get("aiPrompt") or resolvedParams.get("prompt") or "").strip() resultData = getattr(result, "data", None) if resultData and isinstance(resultData, dict): dataField = resultData elif hasattr(result, "model_dump"): dataField = result.model_dump() else: dataField = {"success": result.success, "error": result.error} out = { "success": result.success, "error": result.error, "documents": docsList, "data": dataField, } 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 if outputSchema == "ConsolidateResult" and nodeType == "ai.consolidate": data_dict = result.data if isinstance(getattr(result, "data", None), dict) else {} cr_out = { "result": data_dict.get("result", ""), "mode": data_dict.get("mode", resolvedParams.get("mode", "summarize")), "count": int(data_dict.get("count", 0)), } _attachConnectionProvenance(cr_out, resolvedParams, outputSchema, chatService, self.services) return normalizeToSchema(cr_out, outputSchema) _attachConnectionProvenance(out, resolvedParams, outputSchema, chatService, self.services) return normalizeToSchema(out, outputSchema)