# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Workflow Toolbox - AI-assisted graph manipulation tools for the GraphicalEditor. Tools: readWorkflowGraph, addNode, removeNode, connectNodes, setNodeParameter, listAvailableNodeTypes, validateGraph, listWorkflowHistory, readWorkflowMessages. """ import logging import uuid from typing import Dict, Any, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult logger = logging.getLogger(__name__) TOOLBOX_ID = "workflow" async def _readWorkflowGraph(params: Dict[str, Any], context: Any) -> ToolResult: """Read the current workflow graph (nodes and connections).""" try: workflowId = params.get("workflowId") instanceId = params.get("instanceId") if not workflowId or not instanceId: return ToolResult(success=False, error="workflowId and instanceId required") from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface user = getattr(context, "user", None) mandateId = getattr(context, "mandateId", "") or "" iface = getGraphicalEditorInterface(user, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf: return ToolResult(success=False, error=f"Workflow {workflowId} not found") graph = wf.get("graph", {}) nodes = graph.get("nodes", []) connections = graph.get("connections", []) return ToolResult( success=True, data={ "workflowId": workflowId, "label": wf.get("label", ""), "nodeCount": len(nodes), "connectionCount": len(connections), "nodes": [{"id": n.get("id"), "type": n.get("type"), "title": n.get("title", "")} for n in nodes], "connections": connections, }, ) except Exception as e: logger.exception("readWorkflowGraph failed: %s", e) return ToolResult(success=False, error=str(e)) async def _addNode(params: Dict[str, Any], context: Any) -> ToolResult: """Add a node to the workflow graph.""" try: workflowId = params.get("workflowId") instanceId = params.get("instanceId") nodeType = params.get("nodeType") if not workflowId or not instanceId or not nodeType: return ToolResult(success=False, error="workflowId, instanceId, and nodeType required") from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface user = getattr(context, "user", None) mandateId = getattr(context, "mandateId", "") or "" iface = getGraphicalEditorInterface(user, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf: return ToolResult(success=False, error=f"Workflow {workflowId} not found") graph = dict(wf.get("graph", {})) nodes = list(graph.get("nodes", [])) nodeId = params.get("nodeId") or str(uuid.uuid4())[:8] title = params.get("title", "") nodeParams = params.get("parameters", {}) position = params.get("position", {"x": len(nodes) * 200, "y": 100}) newNode = { "id": nodeId, "type": nodeType, "title": title, "parameters": nodeParams, "position": position, } nodes.append(newNode) graph["nodes"] = nodes iface.updateWorkflow(workflowId, {"graph": graph}) return ToolResult( success=True, data={"nodeId": nodeId, "nodeType": nodeType, "message": f"Node '{title or nodeType}' added"}, ) except Exception as e: logger.exception("addNode failed: %s", e) return ToolResult(success=False, error=str(e)) async def _removeNode(params: Dict[str, Any], context: Any) -> ToolResult: """Remove a node and its connections from the workflow graph.""" try: workflowId = params.get("workflowId") instanceId = params.get("instanceId") nodeId = params.get("nodeId") if not workflowId or not instanceId or not nodeId: return ToolResult(success=False, error="workflowId, instanceId, and nodeId required") from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface user = getattr(context, "user", None) mandateId = getattr(context, "mandateId", "") or "" iface = getGraphicalEditorInterface(user, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf: return ToolResult(success=False, error=f"Workflow {workflowId} not found") graph = dict(wf.get("graph", {})) nodes = [n for n in graph.get("nodes", []) if n.get("id") != nodeId] connections = [ c for c in graph.get("connections", []) if c.get("source") != nodeId and c.get("target") != nodeId ] graph["nodes"] = nodes graph["connections"] = connections iface.updateWorkflow(workflowId, {"graph": graph}) return ToolResult(success=True, data={"nodeId": nodeId, "message": f"Node {nodeId} removed"}) except Exception as e: logger.exception("removeNode failed: %s", e) return ToolResult(success=False, error=str(e)) async def _connectNodes(params: Dict[str, Any], context: Any) -> ToolResult: """Connect two nodes in the workflow graph.""" try: workflowId = params.get("workflowId") instanceId = params.get("instanceId") sourceId = params.get("sourceId") targetId = params.get("targetId") if not workflowId or not instanceId or not sourceId or not targetId: return ToolResult(success=False, error="workflowId, instanceId, sourceId, and targetId required") from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface user = getattr(context, "user", None) mandateId = getattr(context, "mandateId", "") or "" iface = getGraphicalEditorInterface(user, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf: return ToolResult(success=False, error=f"Workflow {workflowId} not found") graph = dict(wf.get("graph", {})) connections = list(graph.get("connections", [])) newConn = { "source": sourceId, "target": targetId, "sourceOutput": params.get("sourceOutput", 0), "targetInput": params.get("targetInput", 0), } connections.append(newConn) graph["connections"] = connections iface.updateWorkflow(workflowId, {"graph": graph}) return ToolResult(success=True, data={"connection": newConn, "message": f"Connected {sourceId} -> {targetId}"}) except Exception as e: logger.exception("connectNodes failed: %s", e) return ToolResult(success=False, error=str(e)) async def _setNodeParameter(params: Dict[str, Any], context: Any) -> ToolResult: """Set a parameter on a node.""" try: workflowId = params.get("workflowId") instanceId = params.get("instanceId") nodeId = params.get("nodeId") paramName = params.get("parameterName") paramValue = params.get("parameterValue") if not workflowId or not instanceId or not nodeId or not paramName: return ToolResult(success=False, error="workflowId, instanceId, nodeId, and parameterName required") from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface user = getattr(context, "user", None) mandateId = getattr(context, "mandateId", "") or "" iface = getGraphicalEditorInterface(user, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf: return ToolResult(success=False, error=f"Workflow {workflowId} not found") graph = dict(wf.get("graph", {})) nodes = list(graph.get("nodes", [])) found = False for n in nodes: if n.get("id") == nodeId: nodeParams = dict(n.get("parameters", {})) nodeParams[paramName] = paramValue n["parameters"] = nodeParams found = True break if not found: return ToolResult(success=False, error=f"Node {nodeId} not found in graph") graph["nodes"] = nodes iface.updateWorkflow(workflowId, {"graph": graph}) return ToolResult(success=True, data={"nodeId": nodeId, "parameter": paramName, "message": f"Parameter '{paramName}' set"}) except Exception as e: logger.exception("setNodeParameter failed: %s", e) return ToolResult(success=False, error=str(e)) async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolResult: """List all available node types for the flow builder.""" try: from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES nodeTypes = [ {"id": n.get("id"), "category": n.get("category"), "label": n.get("label", {}).get("en", n.get("id"))} for n in STATIC_NODE_TYPES ] return ToolResult(success=True, data={"nodeTypes": nodeTypes, "count": len(nodeTypes)}) except Exception as e: logger.exception("listAvailableNodeTypes failed: %s", e) return ToolResult(success=False, error=str(e)) async def _validateGraph(params: Dict[str, Any], context: Any) -> ToolResult: """Validate a workflow graph for common issues.""" try: workflowId = params.get("workflowId") instanceId = params.get("instanceId") if not workflowId or not instanceId: return ToolResult(success=False, error="workflowId and instanceId required") from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface user = getattr(context, "user", None) mandateId = getattr(context, "mandateId", "") or "" iface = getGraphicalEditorInterface(user, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf: return ToolResult(success=False, error=f"Workflow {workflowId} not found") graph = wf.get("graph", {}) nodes = graph.get("nodes", []) connections = graph.get("connections", []) issues: List[str] = [] nodeIds = {n.get("id") for n in nodes} if not nodes: issues.append("Graph has no nodes") hasTrigger = any(n.get("type", "").startswith("trigger.") for n in nodes) if not hasTrigger: issues.append("No trigger node found") for c in connections: if c.get("source") not in nodeIds: issues.append(f"Connection source '{c.get('source')}' not found") if c.get("target") not in nodeIds: issues.append(f"Connection target '{c.get('target')}' not found") connectedNodes = set() for c in connections: connectedNodes.add(c.get("source")) connectedNodes.add(c.get("target")) orphans = [n.get("id") for n in nodes if n.get("id") not in connectedNodes and not n.get("type", "").startswith("trigger.")] if orphans: issues.append(f"Orphan nodes (not connected): {', '.join(orphans)}") return ToolResult( success=True, data={ "valid": len(issues) == 0, "issues": issues, "nodeCount": len(nodes), "connectionCount": len(connections), }, ) except Exception as e: logger.exception("validateGraph failed: %s", e) return ToolResult(success=False, error=str(e)) async def _listWorkflowHistory(params: Dict[str, Any], context: Any) -> ToolResult: """List versions (history) for a workflow.""" try: workflowId = params.get("workflowId", "") instanceId = params.get("instanceId", "") from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface user = getattr(context, "user", None) mandateId = getattr(context, "mandateId", "") or "" iface = getGraphicalEditorInterface(user, mandateId, instanceId) versions = iface.getVersions(workflowId) return ToolResult( success=True, data={ "workflowId": workflowId, "versions": [ { "id": v.get("id"), "versionNumber": v.get("versionNumber"), "status": v.get("status"), "publishedAt": v.get("publishedAt"), "publishedBy": v.get("publishedBy"), } for v in versions ], }, ) except Exception as e: logger.exception("listWorkflowHistory failed: %s", e) return ToolResult(success=False, error=str(e)) async def _readWorkflowMessages(params: Dict[str, Any], context: Any) -> ToolResult: """Read recent run logs/messages for a workflow.""" try: workflowId = params.get("workflowId", "") instanceId = params.get("instanceId", "") from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface user = getattr(context, "user", None) mandateId = getattr(context, "mandateId", "") or "" iface = getGraphicalEditorInterface(user, mandateId, instanceId) from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoRun runs = iface.db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or [] runSummaries = [] for r in sorted(runs, key=lambda x: x.get("startedAt") or 0, reverse=True)[:10]: runSummaries.append({ "runId": r.get("id"), "status": r.get("status"), "startedAt": r.get("startedAt"), "completedAt": r.get("completedAt"), "error": r.get("error"), }) return ToolResult( success=True, data={"workflowId": workflowId, "recentRuns": runSummaries}, ) except Exception as e: logger.exception("readWorkflowMessages failed: %s", e) return ToolResult(success=False, error=str(e)) def getWorkflowToolDefinitions() -> List[Dict[str, Any]]: """Return tool definitions for registration in the ToolRegistry.""" return [ { "name": "readWorkflowGraph", "handler": _readWorkflowGraph, "description": "Read the current workflow graph (nodes and connections)", "parameters": { "type": "object", "properties": { "workflowId": {"type": "string", "description": "Workflow ID"}, "instanceId": {"type": "string", "description": "Feature instance ID"}, }, "required": ["workflowId", "instanceId"], }, "toolSet": TOOLBOX_ID, }, { "name": "addNode", "handler": _addNode, "description": "Add a node to the workflow graph", "parameters": { "type": "object", "properties": { "workflowId": {"type": "string"}, "instanceId": {"type": "string"}, "nodeType": {"type": "string", "description": "Node type (e.g. ai.chat, email.send)"}, "title": {"type": "string", "description": "Human-readable title"}, "parameters": {"type": "object", "description": "Node parameters"}, "position": {"type": "object", "description": "Canvas position {x, y}"}, }, "required": ["workflowId", "instanceId", "nodeType"], }, "toolSet": TOOLBOX_ID, }, { "name": "removeNode", "handler": _removeNode, "description": "Remove a node and its connections from the graph", "parameters": { "type": "object", "properties": { "workflowId": {"type": "string"}, "instanceId": {"type": "string"}, "nodeId": {"type": "string", "description": "ID of the node to remove"}, }, "required": ["workflowId", "instanceId", "nodeId"], }, "toolSet": TOOLBOX_ID, }, { "name": "connectNodes", "handler": _connectNodes, "description": "Connect two nodes in the graph", "parameters": { "type": "object", "properties": { "workflowId": {"type": "string"}, "instanceId": {"type": "string"}, "sourceId": {"type": "string"}, "targetId": {"type": "string"}, "sourceOutput": {"type": "integer", "default": 0}, "targetInput": {"type": "integer", "default": 0}, }, "required": ["workflowId", "instanceId", "sourceId", "targetId"], }, "toolSet": TOOLBOX_ID, }, { "name": "setNodeParameter", "handler": _setNodeParameter, "description": "Set a parameter on a node", "parameters": { "type": "object", "properties": { "workflowId": {"type": "string"}, "instanceId": {"type": "string"}, "nodeId": {"type": "string"}, "parameterName": {"type": "string"}, "parameterValue": {"description": "Value to set (any type)"}, }, "required": ["workflowId", "instanceId", "nodeId", "parameterName", "parameterValue"], }, "toolSet": TOOLBOX_ID, }, { "name": "listAvailableNodeTypes", "handler": _listAvailableNodeTypes, "description": "List all available node types for the flow builder", "parameters": {"type": "object", "properties": {}}, "readOnly": True, "toolSet": TOOLBOX_ID, }, { "name": "validateGraph", "handler": _validateGraph, "description": "Validate a workflow graph for common issues", "parameters": { "type": "object", "properties": { "workflowId": {"type": "string"}, "instanceId": {"type": "string"}, }, "required": ["workflowId", "instanceId"], }, "readOnly": True, "toolSet": TOOLBOX_ID, }, { "name": "listWorkflowHistory", "handler": _listWorkflowHistory, "description": "List version history for a workflow (AutoVersion entries)", "parameters": { "type": "object", "properties": { "workflowId": {"type": "string"}, "instanceId": {"type": "string"}, }, "required": ["workflowId", "instanceId"], }, "readOnly": True, "toolSet": TOOLBOX_ID, }, { "name": "readWorkflowMessages", "handler": _readWorkflowMessages, "description": "Read recent run logs and status for a workflow", "parameters": { "type": "object", "properties": { "workflowId": {"type": "string"}, "instanceId": {"type": "string"}, }, "required": ["workflowId", "instanceId"], }, "readOnly": True, "toolSet": TOOLBOX_ID, }, ]