gateway/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
2026-04-11 19:44:58 +02:00

1381 lines
54 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
GraphicalEditor routes - node-types, execute, workflows, runs, tasks, connections, browse.
"""
import asyncio
import json
import logging
import math
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeDataUsers import _applyFiltersAndSort
from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices
from modules.features.graphicalEditor.nodeRegistry import getNodeTypesForApi
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
from modules.workflows.automation2.executionEngine import executeGraph
from modules.workflows.automation2.runEnvelope import (
default_run_envelope,
merge_run_envelope,
normalize_run_envelope,
)
from modules.features.graphicalEditor.entryPoints import find_invocation
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor")
logger = logging.getLogger(__name__)
def _pickInvocationTitleLabel(title: Any, requestLang: Optional[str]) -> str:
if isinstance(title, str):
return title
if isinstance(title, dict) and title:
picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "")
return str(picked) if picked else ""
return ""
def _build_execute_run_envelope(
body: Dict[str, Any],
workflow: Optional[Dict[str, Any]],
user_id: Optional[str],
requestLang: Optional[str] = None,
) -> Dict[str, Any]:
"""Build normalized run envelope from POST /execute body."""
if isinstance(body.get("runEnvelope"), dict):
env = normalize_run_envelope(body["runEnvelope"], user_id=user_id)
pl = body.get("payload")
if isinstance(pl, dict):
env = merge_run_envelope(env, {"payload": pl})
return env
entry_point_id = body.get("entryPointId")
if entry_point_id:
if not workflow:
raise HTTPException(
status_code=400,
detail=routeApiMsg("entryPointId requires a saved workflow (workflowId must refer to a stored workflow)"),
)
inv = find_invocation(workflow, entry_point_id)
if not inv:
raise HTTPException(status_code=400, detail=routeApiMsg("entryPointId not found on workflow"))
if not inv.get("enabled", True):
raise HTTPException(status_code=400, detail=routeApiMsg("entry point is disabled"))
kind = inv.get("kind", "manual")
trig_map = {
"manual": "manual",
"form": "form",
"schedule": "schedule",
"always_on": "event",
"email": "email",
"webhook": "webhook",
"api": "api",
"event": "event",
}
trig = trig_map.get(kind, "manual")
title = inv.get("title") or {}
label = _pickInvocationTitleLabel(title, requestLang)
base = default_run_envelope(
trig,
entry_point_id=inv.get("id"),
entry_point_label=label or None,
)
pl = body.get("payload")
if isinstance(pl, dict):
base = merge_run_envelope(base, {"payload": pl})
return normalize_run_envelope(base, user_id=user_id)
env = normalize_run_envelope(None, user_id=user_id)
pl = body.get("payload")
if isinstance(pl, dict):
env = merge_run_envelope(env, {"payload": pl})
return env
router = APIRouter(
prefix="/api/workflows",
tags=["GraphicalEditor"],
responses={404: {"description": "Not found"}, 403: {"description": "Forbidden"}},
)
def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
"""Validate user has access to the graphicalEditor feature instance. Returns mandateId."""
from fastapi import HTTPException
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
instance = rootInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found")
featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId)
if not featureAccess or not featureAccess.enabled:
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to this feature instance"))
return str(instance.mandateId) if instance.mandateId else ""
@router.get("/{instanceId}/info")
@limiter.limit("60/minute")
def get_info(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Minimal info endpoint - proves the feature works."""
_validateInstanceAccess(instanceId, context)
return {
"featureCode": "graphicalEditor",
"instanceId": instanceId,
"status": "ok",
"message": "GraphicalEditor feature ready.",
}
@router.post("/{instanceId}/schedule-sync")
@limiter.limit("10/minute")
def post_schedule_sync(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Manually trigger schedule sync (re-register cron jobs for all schedule workflows)."""
_validateInstanceAccess(instanceId, context)
from modules.workflows.scheduler.mainScheduler import syncNow
result = syncNow()
return {"success": True, **(result or {})}
@router.get("/{instanceId}/node-types")
@limiter.limit("60/minute")
def get_node_types(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
language: str = Query("en", description="Localization (en, de, fr)"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Return node types for the flow builder: static + I/O from methodDiscovery."""
logger.info("graphicalEditor node-types request: instanceId=%s language=%s", instanceId, language)
mandateId = _validateInstanceAccess(instanceId, context)
services = getGraphicalEditorServices(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
result = getNodeTypesForApi(services, language=language)
logger.info(
"graphicalEditor node-types response: %d nodeTypes %d categories",
len(result.get("nodeTypes", [])),
len(result.get("categories", [])),
)
return result
@router.post("/{instanceId}/execute")
@limiter.limit("30/minute")
async def post_execute(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
body: dict = Body(..., description="{ workflowId?, graph: { nodes, connections } }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Execute workflow graph. Body: { workflowId?, graph: { nodes, connections } }."""
userId = str(context.user.id) if context.user else None
logger.info(
"graphicalEditor execute request: instanceId=%s userId=%s body_keys=%s",
instanceId,
userId,
list(body.keys()),
)
mandateId = _validateInstanceAccess(instanceId, context)
services = getGraphicalEditorServices(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
discoverMethods(services)
graph = body.get("graph") or body
workflowId = body.get("workflowId")
req_nodes = graph.get("nodes") or []
workflow_for_envelope: Optional[Dict[str, Any]] = None
if workflowId and not str(workflowId).startswith("transient-"):
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
workflow_for_envelope = iface.getWorkflow(workflowId)
if workflowId and len(req_nodes) == 0:
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
wf = iface.getWorkflow(workflowId)
if wf and wf.get("graph"):
graph = wf["graph"]
logger.info("graphicalEditor execute: loaded graph from workflow %s", workflowId)
workflow_for_envelope = wf
if not workflowId:
import uuid
workflowId = f"transient-{uuid.uuid4().hex[:12]}"
logger.info("graphicalEditor execute: using transient workflowId=%s", workflowId)
nodes_count = len(graph.get("nodes") or [])
connections_count = len(graph.get("connections") or [])
logger.info(
"graphicalEditor execute: graph nodes=%d connections=%d workflowId=%s mandateId=%s",
nodes_count,
connections_count,
workflowId,
mandateId,
)
run_env = _build_execute_run_envelope(
body,
workflow_for_envelope,
userId,
getattr(context.user, "language", None) if context.user else None,
)
ge_interface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
result = await executeGraph(
graph=graph,
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=userId,
mandateId=mandateId,
automation2_interface=ge_interface,
run_envelope=run_env,
)
logger.info(
"graphicalEditor execute result: success=%s error=%s nodeOutputs_keys=%s failedNode=%s paused=%s",
result.get("success"),
result.get("error"),
list(result.get("nodeOutputs", {}).keys()) if result.get("nodeOutputs") else [],
result.get("failedNode"),
result.get("paused"),
)
return result
# -------------------------------------------------------------------------
# Run Tracing SSE Stream
# -------------------------------------------------------------------------
@router.get("/{instanceId}/runs/{runId}/stream")
async def get_run_stream(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
runId: str = Path(..., description="Run ID"),
context: RequestContext = Depends(getRequestContext),
):
"""SSE stream for live step-log updates during a workflow run."""
_validateInstanceAccess(instanceId, context)
from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager
sseEventManager = get_event_manager()
queueId = f"run-trace-{runId}"
sseEventManager.create_queue(queueId)
async def _sseGenerator():
queue = sseEventManager.get_queue(queueId)
if not queue:
return
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=30)
except asyncio.TimeoutError:
yield "data: {\"type\": \"keepalive\"}\n\n"
continue
if event is None:
break
payload = event.get("data", event) if isinstance(event, dict) else event
yield f"data: {json.dumps(payload, default=str)}\n\n"
eventType = payload.get("type", "") if isinstance(payload, dict) else ""
if eventType in ("run_complete", "run_failed"):
break
await sseEventManager.cleanup(queueId, delay=10)
return StreamingResponse(
_sseGenerator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# -------------------------------------------------------------------------
# Versions (AutoVersion Lifecycle)
# -------------------------------------------------------------------------
@router.get("/{instanceId}/workflows/{workflowId}/versions")
@limiter.limit("60/minute")
def get_versions(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""List all versions for a workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
versions = iface.getVersions(workflowId)
return {"versions": versions}
@router.post("/{instanceId}/workflows/{workflowId}/versions/draft")
@limiter.limit("30/minute")
def create_draft_version(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Create a new draft version from the workflow's current graph."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
version = iface.createDraftVersion(workflowId)
if not version:
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
return version
@router.post("/{instanceId}/versions/{versionId}/publish")
@limiter.limit("30/minute")
def publish_version(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
versionId: str = Path(..., description="Version ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Publish a draft version. Archives the previously published version."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
userId = str(context.user.id) if context.user else None
version = iface.publishVersion(versionId, userId=userId)
if not version:
raise HTTPException(status_code=400, detail=routeApiMsg("Version not found or not in draft status"))
return version
@router.post("/{instanceId}/versions/{versionId}/unpublish")
@limiter.limit("30/minute")
def unpublish_version(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
versionId: str = Path(..., description="Version ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Unpublish a version (revert to draft)."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
version = iface.unpublishVersion(versionId)
if not version:
raise HTTPException(status_code=400, detail=routeApiMsg("Version not found or not published"))
return version
@router.post("/{instanceId}/versions/{versionId}/archive")
@limiter.limit("30/minute")
def archive_version(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
versionId: str = Path(..., description="Version ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Archive a version."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
version = iface.archiveVersion(versionId)
if not version:
raise HTTPException(status_code=404, detail=routeApiMsg("Version not found"))
return version
# -------------------------------------------------------------------------
# Templates
# -------------------------------------------------------------------------
@router.get("/{instanceId}/templates")
@limiter.limit("60/minute")
def get_templates(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
scope: Optional[str] = Query(None, description="Filter by scope: user, instance, mandate, system"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
context: RequestContext = Depends(getRequestContext),
):
"""List workflow templates with optional pagination."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
templates = iface.getTemplates(scope=scope)
paginationParams = None
if pagination:
try:
paginationDict = json.loads(pagination)
if paginationDict:
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
if paginationParams:
filtered = _applyFiltersAndSort(templates, paginationParams)
totalItems = len(filtered)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
return {
"items": filtered[startIdx:endIdx],
"pagination": PaginationMetadata(
currentPage=paginationParams.page, pageSize=paginationParams.pageSize,
totalItems=totalItems, totalPages=totalPages,
sort=paginationParams.sort, filters=paginationParams.filters,
).model_dump(),
}
return {"templates": templates}
@router.post("/{instanceId}/templates/from-workflow")
@limiter.limit("30/minute")
def create_template_from_workflow(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
body: dict = Body(..., description="{ workflowId, scope? }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Create a template from an existing workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
workflowId = body.get("workflowId")
scope = body.get("scope", "user")
if not workflowId:
raise HTTPException(status_code=400, detail=routeApiMsg("workflowId required"))
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
template = iface.createTemplateFromWorkflow(workflowId, scope=scope)
if not template:
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
return template
@router.post("/{instanceId}/templates/{templateId}/copy")
@limiter.limit("30/minute")
def copy_template(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
templateId: str = Path(..., description="Template ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Copy a template to a new user-owned workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
workflow = iface.copyTemplateToUser(templateId)
if not workflow:
raise HTTPException(status_code=404, detail=routeApiMsg("Template not found"))
return workflow
@router.post("/{instanceId}/templates/{templateId}/share")
@limiter.limit("30/minute")
def share_template(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
templateId: str = Path(..., description="Template ID"),
body: dict = Body(..., description="{ scope }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Share a template by changing its scope."""
mandateId = _validateInstanceAccess(instanceId, context)
scope = body.get("scope")
if not scope or scope not in ("user", "instance", "mandate", "system"):
raise HTTPException(status_code=400, detail=routeApiMsg("scope must be user, instance, mandate, or system"))
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
template = iface.shareTemplate(templateId, scope=scope)
if not template:
raise HTTPException(status_code=404, detail=routeApiMsg("Template not found"))
return template
# -------------------------------------------------------------------------
# AI Chat for Editor
# -------------------------------------------------------------------------
@router.post("/{instanceId}/{workflowId}/chat/stream")
@limiter.limit("30/minute")
async def post_editor_chat(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
body: dict = Body(..., description="{ message, conversationHistory?, userLanguage? }"),
context: RequestContext = Depends(getRequestContext),
):
"""AI chat endpoint for the editor with SSE streaming. Uses workflow tools to mutate the graph."""
mandateId = _validateInstanceAccess(instanceId, context)
message = body.get("message", "")
if not message:
raise HTTPException(status_code=400, detail=routeApiMsg("message required"))
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf:
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
userLanguage = body.get("userLanguage", "de")
conversationHistory = body.get("conversationHistory") or []
fileIds = body.get("fileIds") or []
dataSourceIds = body.get("dataSourceIds") or []
featureDataSourceIds = body.get("featureDataSourceIds") or []
from modules.serviceCenter.core.serviceStreaming import get_event_manager
sseEventManager = get_event_manager()
queueId = f"ge-chat-{workflowId}-{id(request)}"
sseEventManager.create_queue(queueId)
agentTask = asyncio.ensure_future(
_runEditorAgent(
workflowId=workflowId,
queueId=queueId,
prompt=message,
instanceId=instanceId,
user=context.user,
mandateId=mandateId,
sseEventManager=sseEventManager,
userLanguage=userLanguage,
conversationHistory=conversationHistory,
fileIds=fileIds,
dataSourceIds=dataSourceIds,
featureDataSourceIds=featureDataSourceIds,
)
)
sseEventManager.register_agent_task(queueId, agentTask)
async def _sseGenerator():
queue = sseEventManager.get_queue(queueId)
if not queue:
return
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=120)
except asyncio.TimeoutError:
yield "data: {\"type\": \"keepalive\"}\n\n"
continue
if event is None:
break
ssePayload = event.get("data", event) if isinstance(event, dict) else event
yield f"data: {json.dumps(ssePayload, default=str)}\n\n"
eventType = ssePayload.get("type", "") if isinstance(ssePayload, dict) else ""
if eventType in ("complete", "error", "stopped"):
break
await sseEventManager.cleanup(queueId, delay=30)
return StreamingResponse(
_sseGenerator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def _runEditorAgent(
workflowId: str,
queueId: str,
prompt: str,
instanceId: str,
user=None,
mandateId: str = "",
sseEventManager=None,
userLanguage: str = "de",
conversationHistory: List[Dict[str, Any]] = None,
fileIds: List[str] = None,
dataSourceIds: List[str] = None,
featureDataSourceIds: List[str] = None,
):
"""Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue."""
try:
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum
ctx = ServiceCenterContext(
user=user,
mandate_id=mandateId,
feature_instance_id=instanceId,
workflow_id=workflowId,
feature_code="graphicalEditor",
)
agentService = getService("agent", ctx)
systemPrompt = (
"You are a workflow editor assistant. The user describes changes to a workflow graph. "
"Use the available workflow tools (readWorkflowGraph, addNode, removeNode, connectNodes, "
"setNodeParameter, listAvailableNodeTypes, validateGraph) to modify the graph. "
"Always read the current graph first before making changes. "
"Respond concisely and confirm what you changed."
)
enrichedPrompt = prompt
if dataSourceIds:
from modules.features.workspace.routeFeatureWorkspace import _buildDataSourceContext
chatSvc = getService("chat", ctx)
dsInfo = _buildDataSourceContext(chatSvc, dataSourceIds)
if dsInfo:
enrichedPrompt = f"{prompt}\n\n[Active Data Sources]\n{dsInfo}"
if featureDataSourceIds:
from modules.features.workspace.routeFeatureWorkspace import _buildFeatureDataSourceContext
fdsInfo = _buildFeatureDataSourceContext(featureDataSourceIds)
if fdsInfo:
enrichedPrompt = f"{enrichedPrompt}\n\n[Attached Feature Data Sources]\n{fdsInfo}"
accumulatedText = ""
async for event in agentService.runAgent(
prompt=enrichedPrompt,
fileIds=fileIds or [],
workflowId=workflowId,
userLanguage=userLanguage,
conversationHistory=conversationHistory or [],
toolSet="core",
additionalTools=None,
systemPromptOverride=systemPrompt,
):
if sseEventManager.is_cancelled(queueId):
logger.info("Editor chat agent cancelled for workflow %s", workflowId)
break
if event.type == AgentEventTypeEnum.CHUNK and event.content:
accumulatedText += event.content
sseEvent = {
"type": event.type.value if hasattr(event.type, "value") else event.type,
"workflowId": workflowId,
}
if event.content:
sseEvent["content"] = event.content
if event.data:
sseEvent["item"] = event.data
await sseEventManager.emit_event(queueId, sseEvent["type"], sseEvent)
if event.type in (AgentEventTypeEnum.FINAL, AgentEventTypeEnum.ERROR):
break
await sseEventManager.emit_event(queueId, "complete", {
"type": "complete",
"workflowId": workflowId,
})
except asyncio.CancelledError:
logger.info("Editor chat agent task cancelled for workflow %s", workflowId)
await sseEventManager.emit_event(queueId, "stopped", {
"type": "stopped",
"workflowId": workflowId,
})
except Exception as e:
logger.error("Editor chat agent error: %s", e, exc_info=True)
await sseEventManager.emit_event(queueId, "error", {
"type": "error",
"content": str(e),
"workflowId": workflowId,
})
finally:
sseEventManager._unregister_agent_task(queueId)
# -------------------------------------------------------------------------
# Connections and Browse (for Email/SharePoint node config)
# -------------------------------------------------------------------------
def _buildResolverDbInterface(chatService):
"""Build a DB adapter that ConnectorResolver can use to load UserConnections."""
class _ResolverDbAdapter:
def __init__(self, appInterface):
self._app = appInterface
def getUserConnection(self, connectionId: str):
if hasattr(self._app, "getUserConnectionById"):
return self._app.getUserConnectionById(connectionId)
return None
appIf = getattr(chatService, "interfaceDbApp", None)
if appIf:
return _ResolverDbAdapter(appIf)
return getattr(chatService, "interfaceDbComponent", None)
@router.get("/{instanceId}/connections")
@limiter.limit("300/minute")
def list_connections(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Return the user's active connections (UserConnections) for Email/SharePoint node config."""
mandateId = _validateInstanceAccess(instanceId, context)
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else mandateId,
feature_instance_id=instanceId,
)
chatService = getService("chat", ctx)
connections = chatService.getUserConnections()
items = []
for c in connections or []:
conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {})
authority = conn.get("authority")
if hasattr(authority, "value"):
authority = authority.value
status = conn.get("status")
if hasattr(status, "value"):
status = status.value
items.append({
"id": conn.get("id"),
"authority": authority,
"externalUsername": conn.get("externalUsername"),
"externalEmail": conn.get("externalEmail"),
"status": status,
})
return {"connections": items}
@router.get("/{instanceId}/connections/{connectionId}/services")
@limiter.limit("120/minute")
async def list_connection_services(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
connectionId: str = Path(..., description="Connection ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Return the available services for a specific UserConnection."""
mandateId = _validateInstanceAccess(instanceId, context)
try:
from modules.connectors.connectorResolver import ConnectorResolver
from modules.serviceCenter import getService as getSvc
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else mandateId,
feature_instance_id=instanceId,
)
chatService = getSvc("chat", ctx)
securityService = getSvc("security", ctx)
dbInterface = _buildResolverDbInterface(chatService)
resolver = ConnectorResolver(securityService, dbInterface)
provider = await resolver.resolve(connectionId)
services = provider.getAvailableServices()
_serviceLabels = {
"sharepoint": "SharePoint",
"clickup": "ClickUp",
"outlook": "Outlook",
"teams": "Teams",
"onedrive": "OneDrive",
"drive": "Google Drive",
"gmail": "Gmail",
"files": "Files (FTP)",
}
_serviceIcons = {
"sharepoint": "sharepoint",
"clickup": "folder",
"outlook": "mail",
"teams": "chat",
"onedrive": "cloud",
"drive": "cloud",
"gmail": "mail",
"files": "folder",
}
items = [
{"service": s, "label": _serviceLabels.get(s, s), "icon": _serviceIcons.get(s, "folder")}
for s in services
]
return {"services": items}
except Exception as e:
logger.error(f"Error listing services for connection {connectionId}: {e}")
return JSONResponse({"services": [], "error": str(e)}, status_code=400)
@router.get("/{instanceId}/connections/{connectionId}/browse")
@limiter.limit("300/minute")
async def browse_connection_service(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
connectionId: str = Path(..., description="Connection ID"),
service: str = Query(..., description="Service name (e.g. sharepoint, onedrive, outlook)"),
path: str = Query("/", description="Path within the service to browse"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Browse folders/items within a connection's service at a given path."""
mandateId = _validateInstanceAccess(instanceId, context)
try:
from modules.connectors.connectorResolver import ConnectorResolver
from modules.serviceCenter import getService as getSvc
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else mandateId,
feature_instance_id=instanceId,
)
chatService = getSvc("chat", ctx)
securityService = getSvc("security", ctx)
dbInterface = _buildResolverDbInterface(chatService)
resolver = ConnectorResolver(securityService, dbInterface)
adapter = await resolver.resolveService(connectionId, service)
entries = await adapter.browse(path, filter=None)
items = []
for entry in (entries or []):
items.append({
"name": entry.name,
"path": entry.path,
"isFolder": entry.isFolder,
"size": entry.size,
"mimeType": entry.mimeType,
"metadata": entry.metadata if hasattr(entry, "metadata") else {},
})
return {"items": items, "path": path, "service": service}
except Exception as e:
logger.error(f"Error browsing {service} for connection {connectionId} at '{path}': {e}")
return JSONResponse({"items": [], "error": str(e)}, status_code=400)
# -------------------------------------------------------------------------
# Workflow CRUD
# -------------------------------------------------------------------------
def _get_node_label_from_graph(graph: dict, nodeId: str) -> str:
"""Extract human-readable label for a node from graph."""
if not graph or not nodeId:
return nodeId or ""
nodes = graph.get("nodes") or []
for n in nodes:
if n.get("id") == nodeId:
params = n.get("parameters") or {}
config = params.get("config") or {}
if isinstance(config, dict):
label = config.get("title") or config.get("label")
else:
label = None
return (
n.get("title")
or label
or params.get("title")
or params.get("label")
or n.get("type", "")
or nodeId
)
return nodeId or ""
@router.get("/{instanceId}/workflows")
@limiter.limit("60/minute")
def get_workflows(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
active: Optional[bool] = Query(None, description="Filter by active: true|false"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
context: RequestContext = Depends(getRequestContext),
):
"""List all workflows for this feature instance."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
items = iface.getWorkflows(active=active)
enriched = []
for wf in items:
wf_id = wf.get("id")
runs = iface.getRunsByWorkflow(wf_id) if wf_id else []
run_count = len(runs)
active_run = None
last_started_at = None
for r in runs:
ts = r.get("sysCreatedAt")
if ts and (last_started_at is None or ts > last_started_at):
last_started_at = ts
if r.get("status") in ("running", "paused"):
active_run = r
stuck_at_node_id = active_run.get("currentNodeId") if active_run else None
stuck_at_node_label = ""
if stuck_at_node_id and wf.get("graph"):
stuck_at_node_label = _get_node_label_from_graph(wf["graph"], stuck_at_node_id)
enriched.append({
**wf,
"runCount": run_count,
"isRunning": active_run is not None,
"runStatus": active_run.get("status") if active_run else None,
"stuckAtNodeId": stuck_at_node_id,
"stuckAtNodeLabel": stuck_at_node_label or stuck_at_node_id or "",
"createdAt": wf.get("sysCreatedAt"),
"lastStartedAt": last_started_at,
})
paginationParams = None
if pagination:
try:
paginationDict = json.loads(pagination)
if paginationDict:
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
if paginationParams:
filtered = _applyFiltersAndSort(enriched, paginationParams)
totalItems = len(filtered)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
return {
"items": filtered[startIdx:endIdx],
"pagination": PaginationMetadata(
currentPage=paginationParams.page, pageSize=paginationParams.pageSize,
totalItems=totalItems, totalPages=totalPages,
sort=paginationParams.sort, filters=paginationParams.filters,
).model_dump(),
}
return {"workflows": enriched}
@router.get("/{instanceId}/workflows/{workflowId}")
@limiter.limit("60/minute")
def get_workflow(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get a single workflow by ID."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf:
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
return wf
@router.post("/{instanceId}/workflows")
@limiter.limit("30/minute")
def create_workflow(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
body: dict = Body(..., description="{ label, graph }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Create a new workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
created = iface.createWorkflow(body)
return created
@router.put("/{instanceId}/workflows/{workflowId}")
@limiter.limit("30/minute")
def update_workflow(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
body: dict = Body(..., description="{ label?, graph? }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Update a workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
updated = iface.updateWorkflow(workflowId, body)
if not updated:
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
return updated
@router.delete("/{instanceId}/workflows/{workflowId}")
@limiter.limit("30/minute")
def delete_workflow(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Delete a workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
if not iface.deleteWorkflow(workflowId):
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
return {"success": True}
@router.post("/{instanceId}/workflows/{workflowId}/webhooks/{entryPointId}")
@limiter.limit("60/minute")
async def post_workflow_webhook(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
entryPointId: str = Path(..., description="Entry point ID (kind must be webhook)"),
body: dict = Body(default_factory=dict),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Invoke a workflow via a webhook entry point."""
mandateId = _validateInstanceAccess(instanceId, context)
userId = str(context.user.id) if context.user else None
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf or not wf.get("graph"):
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
inv = find_invocation(wf, entryPointId)
if not inv:
raise HTTPException(status_code=404, detail=routeApiMsg("Entry point not found"))
if inv.get("kind") != "webhook":
raise HTTPException(status_code=400, detail=routeApiMsg("Entry point is not a webhook"))
if not inv.get("enabled", True):
raise HTTPException(status_code=400, detail=routeApiMsg("Entry point is disabled"))
cfg = inv.get("config") or {}
secret = cfg.get("webhookSecret")
if secret:
hdr = request.headers.get("X-Webhook-Secret")
if hdr != str(secret):
raise HTTPException(status_code=403, detail=routeApiMsg("Invalid webhook secret"))
services = getGraphicalEditorServices(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
discoverMethods(services)
title = inv.get("title") or {}
label = _pickInvocationTitleLabel(
title,
getattr(context.user, "language", None) if context.user else None,
)
pl = body if isinstance(body, dict) else {}
base = default_run_envelope(
"webhook",
entry_point_id=inv.get("id"),
entry_point_label=label or None,
payload=pl,
raw={"httpBody": body},
)
run_env = normalize_run_envelope(base, user_id=userId)
result = await executeGraph(
graph=wf["graph"],
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=userId,
mandateId=mandateId,
automation2_interface=iface,
run_envelope=run_env,
)
return result
@router.post("/{instanceId}/workflows/{workflowId}/forms/{entryPointId}/submit")
@limiter.limit("60/minute")
async def post_workflow_form_submit(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
entryPointId: str = Path(..., description="Entry point ID (kind must be form)"),
body: dict = Body(default_factory=dict),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Form-style submit: same as execute with trigger.type form and payload from body."""
mandateId = _validateInstanceAccess(instanceId, context)
userId = str(context.user.id) if context.user else None
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf or not wf.get("graph"):
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
inv = find_invocation(wf, entryPointId)
if not inv:
raise HTTPException(status_code=404, detail=routeApiMsg("Entry point not found"))
if inv.get("kind") != "form":
raise HTTPException(status_code=400, detail=routeApiMsg("Entry point is not a form"))
if not inv.get("enabled", True):
raise HTTPException(status_code=400, detail=routeApiMsg("Entry point is disabled"))
services = getGraphicalEditorServices(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
discoverMethods(services)
title = inv.get("title") or {}
label = _pickInvocationTitleLabel(
title,
getattr(context.user, "language", None) if context.user else None,
)
pl = body if isinstance(body, dict) else {}
base = default_run_envelope(
"form",
entry_point_id=inv.get("id"),
entry_point_label=label or None,
payload=pl,
raw={"formBody": body},
)
run_env = normalize_run_envelope(base, user_id=userId)
result = await executeGraph(
graph=wf["graph"],
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=userId,
mandateId=mandateId,
automation2_interface=iface,
run_envelope=run_env,
)
return result
# -------------------------------------------------------------------------
# Runs and Resume
# -------------------------------------------------------------------------
@router.get("/{instanceId}/runs/completed")
@limiter.limit("60/minute")
def get_completed_runs(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
limit: int = Query(20, ge=1, le=50),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get recently completed runs with output."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
runs = iface.getRecentCompletedRuns(limit=limit)
return {"runs": runs}
@router.get("/{instanceId}/workflows/{workflowId}/runs")
@limiter.limit("60/minute")
def get_workflow_runs(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get runs for a workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
if not iface.getWorkflow(workflowId):
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
runs = iface.getRunsByWorkflow(workflowId)
return {"runs": runs}
@router.get("/{instanceId}/runs/{runId}/steps")
@limiter.limit("60/minute")
def get_run_steps(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
runId: str = Path(..., description="Run ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get step logs for a run (AutoStepLog entries)."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoStepLog
if not iface.db._ensureTableExists(AutoStepLog):
return {"steps": []}
records = iface.db.getRecordset(AutoStepLog, recordFilter={"runId": runId})
steps = [dict(r) for r in records] if records else []
steps.sort(key=lambda s: s.get("startedAt") or 0)
return {"steps": steps}
@router.post("/{instanceId}/runs/{runId}/resume")
@limiter.limit("30/minute")
async def resume_run(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
runId: str = Path(..., description="Run ID"),
body: dict = Body(..., description="{ taskId, result }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Resume a paused run after task completion."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
run = iface.getRun(runId)
if not run:
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
taskId = body.get("taskId")
result = body.get("result")
if not taskId or result is None:
raise HTTPException(status_code=400, detail=routeApiMsg("taskId and result required"))
task = iface.getTask(taskId)
if not task or task.get("runId") != runId:
raise HTTPException(status_code=404, detail=routeApiMsg("Task not found"))
if task.get("status") != "pending":
raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed"))
iface.updateTask(taskId, status="completed", result=result)
nodeId = task.get("nodeId")
nodeOutputs = dict(run.get("nodeOutputs") or {})
nodeOutputs[nodeId] = result
workflowId = run.get("workflowId")
wf = iface.getWorkflow(workflowId) if workflowId else None
if not wf or not wf.get("graph"):
raise HTTPException(status_code=400, detail=routeApiMsg("Workflow graph not found"))
graph = wf["graph"]
services = getGraphicalEditorServices(context.user, mandateId=mandateId, featureInstanceId=instanceId)
resume_result = await executeGraph(
graph=graph,
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=str(context.user.id) if context.user else None,
mandateId=mandateId,
automation2_interface=iface,
initialNodeOutputs=nodeOutputs,
startAfterNodeId=nodeId,
runId=runId,
)
return resume_result
# -------------------------------------------------------------------------
# Tasks
# -------------------------------------------------------------------------
@router.get("/{instanceId}/tasks")
@limiter.limit("60/minute")
def get_tasks(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Query(None, description="Filter by workflow ID"),
status: str = Query(None, description="Filter: pending, completed, rejected"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get tasks assigned to current user."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
assigneeId = str(context.user.id) if context.user else None
items = iface.getTasks(workflowId=workflowId, status=status, assigneeId=assigneeId)
workflows = {w["id"]: w for w in iface.getWorkflows()}
enriched = []
for t in items:
wf = workflows.get(t.get("workflowId") or "")
enriched.append({
**t,
"workflowLabel": wf.get("label", t.get("workflowId", "")) if wf else t.get("workflowId", ""),
"createdAt": t.get("sysCreatedAt"),
})
return {"tasks": enriched}
@router.post("/{instanceId}/tasks/{taskId}/complete")
@limiter.limit("30/minute")
async def complete_task(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
taskId: str = Path(..., description="Task ID"),
body: dict = Body(..., description="{ result }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Complete a task and resume the run."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
task = iface.getTask(taskId)
if not task:
raise HTTPException(status_code=404, detail=routeApiMsg("Task not found"))
runId = task.get("runId")
result = body.get("result")
if result is None:
raise HTTPException(status_code=400, detail=routeApiMsg("result required"))
run = iface.getRun(runId)
if not run:
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
if task.get("status") != "pending":
raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed"))
iface.updateTask(taskId, status="completed", result=result)
nodeId = task.get("nodeId")
nodeOutputs = dict(run.get("nodeOutputs") or {})
nodeOutputs[nodeId] = result
workflowId = run.get("workflowId")
wf = iface.getWorkflow(workflowId) if workflowId else None
if not wf or not wf.get("graph"):
raise HTTPException(status_code=400, detail=routeApiMsg("Workflow graph not found"))
graph = wf["graph"]
services = getGraphicalEditorServices(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return await executeGraph(
graph=graph,
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=str(context.user.id) if context.user else None,
mandateId=mandateId,
automation2_interface=iface,
initialNodeOutputs=nodeOutputs,
startAfterNodeId=nodeId,
runId=runId,
)
# -------------------------------------------------------------------------
# Monitoring / Metrics
# -------------------------------------------------------------------------
@router.get("/{instanceId}/metrics")
@limiter.limit("60/minute")
def get_metrics(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Aggregated metrics for the monitoring dashboard."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
AutoWorkflow, AutoRun, AutoStepLog, AutoTask,
)
workflows = iface.db.getRecordset(AutoWorkflow, recordFilter={
"mandateId": mandateId, "featureInstanceId": instanceId, "isTemplate": False,
}) or []
runs = iface.db.getRecordset(AutoRun, recordFilter={
"workflowId": {"$in": [w.get("id") for w in workflows]} if workflows else "__none__",
}) or []
tasks = iface.db.getRecordset(AutoTask, recordFilter={
"workflowId": {"$in": [w.get("id") for w in workflows]} if workflows else "__none__",
}) or []
runsByStatus = {}
totalTokens = 0
totalCredits = 0.0
for r in runs:
s = r.get("status", "unknown")
runsByStatus[s] = runsByStatus.get(s, 0) + 1
totalTokens += r.get("costTokens", 0) or 0
totalCredits += r.get("costCredits", 0.0) or 0.0
tasksByStatus = {}
for t in tasks:
s = t.get("status", "unknown")
tasksByStatus[s] = tasksByStatus.get(s, 0) + 1
return {
"workflowCount": len(workflows),
"activeWorkflows": sum(1 for w in workflows if w.get("active")),
"totalRuns": len(runs),
"runsByStatus": runsByStatus,
"totalTasks": len(tasks),
"tasksByStatus": tasksByStatus,
"totalTokens": totalTokens,
"totalCredits": round(totalCredits, 4),
}