479 lines
20 KiB
Python
479 lines
20 KiB
Python
# 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,
|
|
},
|
|
]
|