diff --git a/app.py b/app.py index d8104fad..2ecf3ad5 100644 --- a/app.py +++ b/app.py @@ -432,7 +432,7 @@ async def lifespan(app: FastAPI): try: main_loop = asyncio.get_running_loop() eventManager.set_event_loop(main_loop) - from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback + from modules.workflowAutomation.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback setSchedulerMainLoop(main_loop) # Inject run-failed notification callback (Composition Root — avoids workflows→serviceCenter import) @@ -452,10 +452,10 @@ async def lifespan(app: FastAPI): user=eventUser, mandate_id=mandateId or "", feature_instance_id="", - feature_code="graphicalEditor", + feature_code="workflowAutomation", ) messagingService = getService("messaging", ctx) - subscriptionId = "GraphicalEditorRunFailed" + subscriptionId = "WorkflowAutomationRunFailed" eventParams = MessagingEventParameters(triggerData={ "workflowId": workflowId, "workflowLabel": workflowLabel or workflowId, @@ -484,7 +484,7 @@ async def lifespan(app: FastAPI): # --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) --- try: - from modules.workflows.scheduler.mainScheduler import start as _startWorkflowScheduler + from modules.workflowAutomation.scheduler.mainScheduler import start as _startWorkflowScheduler _startWorkflowScheduler(eventUser) logger.info("WorkflowAutomation scheduler started (system lifespan)") except Exception as e: @@ -572,12 +572,12 @@ async def lifespan(app: FastAPI): # 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan) try: - from modules.workflows.scheduler.mainScheduler import stop as _stopWorkflowScheduler + from modules.workflowAutomation.scheduler.mainScheduler import stop as _stopWorkflowScheduler _stopWorkflowScheduler() except Exception as e: logger.warning(f"WorkflowAutomation scheduler stop failed: {e}") try: - from modules.features.graphicalEditor.emailPoller import stop as _stopEmailPoller + from modules.workflowAutomation.scheduler.emailPoller import stop as _stopEmailPoller _stopEmailPoller(eventUser) except Exception as e: logger.warning(f"Email poller stop failed: {e}") @@ -863,12 +863,6 @@ from modules.routes.routeSystem import router as systemRouter, navigationRouter app.include_router(systemRouter) app.include_router(navigationRouter) -from modules.routes.routeWorkflowDashboard import router as workflowDashboardRouter -app.include_router(workflowDashboardRouter) - -from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter -app.include_router(automationWorkspaceRouter) - from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter app.include_router(workflowAutomationRouter) diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py index 93eae82a..7b4e21eb 100644 --- a/modules/datamodels/datamodelChat.py +++ b/modules/datamodels/datamodelChat.py @@ -131,7 +131,7 @@ class ChatWorkflow(PowerOnModel): None, description=( "Optional foreign key linking this chat to an entity outside the " - "ChatWorkflow table (e.g. an Automation2Workflow in the GraphicalEditor " + "ChatWorkflow table (e.g. an Automation2Workflow in WorkflowAutomation " "AI editor chat). NULL for the default workspace chats. Combined with " "featureInstanceId this gives a 1:1 relation entity ↔ chat per feature." ), diff --git a/modules/datamodels/datamodelNavigation.py b/modules/datamodels/datamodelNavigation.py index eb9d3b69..22f851c8 100644 --- a/modules/datamodels/datamodelNavigation.py +++ b/modules/datamodels/datamodelNavigation.py @@ -120,14 +120,6 @@ NAVIGATION_SECTIONS = [ "path": "/billing/transactions", "order": 20, }, - { - "id": "automations", - "objectKey": "ui.system.automations", - "label": t("Automations"), - "icon": "FaRobot", - "path": "/automations", - "order": 30, - }, { "id": "rag-inventory", "objectKey": "ui.system.ragInventory", diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py index 0f7b0863..62b523d1 100644 --- a/modules/demoConfigs/investorDemo2026.py +++ b/modules/demoConfigs/investorDemo2026.py @@ -44,7 +44,6 @@ _USER = { _FEATURES_HAPPYLIFE = [ {"code": "workspace", "label": "Dokumentenablage"}, {"code": "trustee", "label": "Buchhaltung"}, - {"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component {"code": "neutralization", "label": "Datenschutz"}, ] _FEATURES_ALPINA = [ @@ -52,7 +51,6 @@ _FEATURES_ALPINA = [ {"code": "trustee", "label": "BUHA Müller Immobilien GmbH"}, {"code": "trustee", "label": "BUHA Schneider Gastro AG"}, {"code": "trustee", "label": "BUHA Weber Consulting"}, - {"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component {"code": "neutralization", "label": "Datenschutz"}, ] @@ -492,8 +490,8 @@ class InvestorDemo2026(BaseDemoConfig): if not instId: continue - if featureCode == "graphicalEditor": - self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary) + if featureCode == "workflowAutomation": + self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary) if featureCode == "trustee": self._removeTrusteeData(db, instId, mandateLabel, summary) @@ -551,10 +549,10 @@ class InvestorDemo2026(BaseDemoConfig): except Exception as e: summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}") - def _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): - """Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the Greenfield DB.""" + def _removeWorkflowAutomationData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): + """Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the WorkflowAutomation DB.""" try: - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( + from modules.datamodels.datamodelWorkflowAutomation import ( AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, ) from modules.connectors.connectorDbPostgre import DatabaseConnector @@ -596,10 +594,10 @@ class InvestorDemo2026(BaseDemoConfig): if workflows: summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}") - logger.info(f"Removed {len(workflows)} graphical editor workflows for {mandateLabel}") + logger.info(f"Removed {len(workflows)} automation workflows for {mandateLabel}") except Exception as e: - summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}") - logger.error(f"Failed to clean up graphical editor data for {mandateLabel}: {e}") + summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}") + logger.error(f"Failed to clean up workflow automation data for {mandateLabel}: {e}") def _removeTrusteeData(self, db, featureInstanceId: str, mandateLabel: str, summary: Dict): """Remove TrusteeAccountingConfig for a feature instance.""" diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py index 6e21e45a..c2c196af 100644 --- a/modules/demoConfigs/pwgDemo2026.py +++ b/modules/demoConfigs/pwgDemo2026.py @@ -4,8 +4,7 @@ Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install: - 1 mandate "Stiftung PWG" - 1 SysAdmin demo user "pwg.demo" - - 4 features: workspace, trustee (BUHA PWG), graphicalEditor (PWG Automationen), - neutralization (Datenschutz) + - 3 features: workspace, trustee (BUHA PWG), neutralization (Datenschutz) - Trustee seed-data (5 fictitious tenants with monthly rent journal lines for the current year, loaded from ``demoData/pwg/_seedTrusteeData.json``) - Pilot workflow imported from @@ -49,7 +48,6 @@ _USER = { _FEATURES_PWG = [ {"code": "workspace", "label": "Dokumentenablage PWG"}, {"code": "trustee", "label": "Buchhaltung PWG"}, - {"code": "graphicalEditor", "label": "PWG Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component {"code": "neutralization", "label": "Datenschutz"}, ] @@ -98,9 +96,6 @@ class PwgDemo2026(BaseDemoConfig): if trusteeInstanceId: self._ensureTrusteeSeed(mandateId, trusteeInstanceId, summary) - graphInstanceId = self._getFeatureInstanceId(db, mandateId, "graphicalEditor", "PWG Automationen") - if graphInstanceId: - self._ensurePilotWorkflow(mandateId, graphInstanceId, summary) except Exception as e: logger.error(f"PWG demo load failed: {e}", exc_info=True) @@ -542,11 +537,11 @@ class PwgDemo2026(BaseDemoConfig): summary["skipped"].append(f"PWG seed: {skippedTenants} tenants already present") def _ensurePilotWorkflow(self, mandateId: str, featureInstanceId: str, summary: Dict): - """Import the pilot workflow JSON into the graphical-editor DB. + """Import the pilot workflow JSON into the WorkflowAutomation DB. Uses the schema-aware import pipeline introduced in Phase 1 (``_workflowFileSchema.envelopeToWorkflowData`` + - ``GraphicalEditorObjects.importWorkflowFromDict``). The workflow is + ``WorkflowAutomationObjects.importWorkflowFromDict``). The workflow is always created with ``active=False`` so a manual trigger is required — this matches the demo-bootstrap safety default. """ @@ -561,17 +556,17 @@ class PwgDemo2026(BaseDemoConfig): return try: - geDb = _openGraphicalEditorDb() + geDb = _openWorkflowAutomationDb() except Exception as exc: - summary["errors"].append(f"GraphicalEditor DB connection failed: {exc}") + summary["errors"].append(f"WorkflowAutomation DB connection failed: {exc}") return - from modules.features.graphicalEditor._workflowFileSchema import ( + from modules.workflowAutomation.editor._workflowFileSchema import ( envelopeToWorkflowData, validateFileEnvelope, ) - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow - from modules.features.graphicalEditor.nodeRegistry import STATIC_NODE_TYPES + from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow + from modules.workflowAutomation.editor.nodeRegistry import STATIC_NODE_TYPES existing = geDb.getRecordset(AutoWorkflow, recordFilter={ "mandateId": mandateId, @@ -625,7 +620,7 @@ class PwgDemo2026(BaseDemoConfig): ) created = geDb.recordCreate(AutoWorkflow, record) summary["created"].append(f"Pilot workflow imported (active=false, id={created.get('id')})") - logger.info(f"Imported pilot workflow into graphicalEditor instance {featureInstanceId}") + logger.info(f"Imported pilot workflow into workflowAutomation instance {featureInstanceId}") def _guessTrusteeInstanceId(self, mandateId: str) -> Optional[str]: """Return the first trustee feature-instance id of the given mandate. @@ -678,8 +673,8 @@ class PwgDemo2026(BaseDemoConfig): if not instId: continue - if featureCode == "graphicalEditor": - self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary) + if featureCode == "workflowAutomation": + self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary) if featureCode == "trustee": self._removeTrusteeSeed(instId, mandateLabel, summary) if featureCode == "neutralization": @@ -724,16 +719,16 @@ class PwgDemo2026(BaseDemoConfig): except Exception as e: summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}") - def _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): + def _removeWorkflowAutomationData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): try: - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( + from modules.datamodels.datamodelWorkflowAutomation import ( AutoRun, AutoStepLog, AutoTask, AutoVersion, AutoWorkflow, ) - geDb = _openGraphicalEditorDb() + geDb = _openWorkflowAutomationDb() workflows = geDb.getRecordset(AutoWorkflow, recordFilter={ "mandateId": mandateId, "featureInstanceId": featureInstanceId, @@ -753,7 +748,7 @@ class PwgDemo2026(BaseDemoConfig): if workflows: summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}") except Exception as e: - summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}") + summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}") def _removeTrusteeSeed(self, featureInstanceId: str, mandateLabel: str, summary: Dict): try: @@ -818,7 +813,7 @@ def _openTrusteeDb(): ) -def _openGraphicalEditorDb(): +def _openWorkflowAutomationDb(): """Open a privileged DB connection to ``poweron_graphicaleditor``.""" from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG diff --git a/modules/features/graphicalEditor/__init__.py b/modules/features/graphicalEditor/__init__.py deleted file mode 100644 index bb8c0a4b..00000000 --- a/modules/features/graphicalEditor/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# GraphicalEditor feature - n8n-style flow automation with visual editor diff --git a/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py b/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py deleted file mode 100644 index 1e701716..00000000 --- a/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""GraphicalEditor models — re-exports from canonical datamodels.datamodelWorkflowAutomation.""" - -# All models and enums re-exported for backward compatibility. -# Canonical location: modules.datamodels.datamodelWorkflowAutomation -from modules.datamodels.datamodelWorkflowAutomation import ( # noqa: F401 - AutoWorkflowStatus, - AutoRunStatus, - AutoStepStatus, - AutoTaskStatus, - AutoTemplateScope, - GRAPHICAL_EDITOR_DATABASE, - AutoWorkflow, - AutoVersion, - AutoRun, - AutoStepLog, - AutoTask, - Automation2Workflow, - Automation2WorkflowRun, - Automation2HumanTask, -) - -# Legacy alias -graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py deleted file mode 100644 index 38d9d769..00000000 --- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py +++ /dev/null @@ -1,1880 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -DEPRECATED: These per-instance routes are superseded by /api/workflow-automation/ -(routeWorkflowAutomation.py). Kept for backward compatibility during migration. - -Original: GraphicalEditor routes - node-types, execute, workflows, runs, tasks, connections, browse. -""" - -import asyncio -import json -import logging -import math -import uuid -from typing import Any, Dict, List, Optional - -from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException -from fastapi.responses import JSONResponse, StreamingResponse, Response -from modules.auth import limiter, getRequestContext, RequestContext -from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict -from modules.dbHelpers.paginationHelpers 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.features.graphicalEditor.conditionOperators import resolve_condition_meta -from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths, compute_graph_data_sources -from modules.shared.i18nRegistry import apiRouteContext, resolveText -routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor") - -logger = logging.getLogger(__name__) - - -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 = resolveText(title) - 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 "" - - -def _validateTargetInstance( - workflowData: Dict[str, Any], - ownerInstanceId: str, - context: RequestContext, -) -> None: - """Enforce targetFeatureInstanceId rules for non-template workflows. - - - Templates (isTemplate=True) may omit targetFeatureInstanceId. - - Non-templates MUST have a non-empty targetFeatureInstanceId. - - If the targetFeatureInstanceId differs from the GE owner instance, - the user must also have FeatureAccess on that target instance. - """ - if workflowData.get("isTemplate"): - return - - targetId = workflowData.get("targetFeatureInstanceId") - if not targetId: - return - - if targetId == ownerInstanceId: - return - - from modules.interfaces.interfaceDbApp import getRootInterface - rootInterface = getRootInterface() - targetInstance = rootInterface.getFeatureInstance(targetId) - if not targetInstance: - raise HTTPException( - status_code=400, - detail=routeApiMsg("targetFeatureInstanceId refers to a non-existent feature instance"), - ) - targetAccess = rootInterface.getFeatureAccess(str(context.user.id), targetId) - if not targetAccess or not targetAccess.enabled: - raise HTTPException( - status_code=403, - detail=routeApiMsg("Access denied to target feature instance"), - ) - - -@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}/upstream-paths") -@limiter.limit("60/minute") -def post_upstream_paths( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: Dict[str, Any] = Body(...), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return pickable upstream DataRef paths for a node (draft graph in body).""" - _validateInstanceAccess(instanceId, context) - graph = body.get("graph") - node_id = body.get("nodeId") - if not isinstance(graph, dict) or not node_id: - raise HTTPException(status_code=400, detail=routeApiMsg("graph and nodeId are required")) - paths = compute_upstream_paths(graph, str(node_id)) - return {"paths": paths} - - -@router.post("/{instanceId}/condition-meta") -@limiter.limit("120/minute") -def post_condition_meta( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: Dict[str, Any] = Body(...), - language: str = Query("de", description="Localization (en, de, fr)"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return valueKind and operators for a DataRef (backend-driven If/Else UI).""" - _validateInstanceAccess(instanceId, context) - graph = body.get("graph") - ref = body.get("ref") - node_id = body.get("nodeId") - if not isinstance(graph, dict) or not isinstance(ref, dict): - raise HTTPException(status_code=400, detail=routeApiMsg("graph and ref are required")) - graph_payload = dict(graph) - if node_id: - graph_payload["targetNodeId"] = str(node_id) - return resolve_condition_meta(graph_payload, ref, lang=language) - - -@router.post("/{instanceId}/graph-data-sources") -@limiter.limit("120/minute") -def post_graph_data_sources( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: Dict[str, Any] = Body(...), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Scope-aware data sources for the DataPicker. - - Takes ``{ nodeId, graph: { nodes, connections } }`` and returns:: - - { - "availableSourceIds": [...], # ancestors minus loop-body nodes on Done branch - "portIndexOverrides": {nodeId: n}, # use outputPorts[n] instead of 0 - "loopBodyContextIds": [...], # loops whose body the node is in - } - - All loop scope logic lives here so the frontend has zero topology knowledge. - """ - _validateInstanceAccess(instanceId, context) - graph = body.get("graph") - node_id = body.get("nodeId") - if not isinstance(graph, dict) or not node_id: - raise HTTPException(status_code=400, detail=routeApiMsg("graph and nodeId are required")) - return compute_graph_data_sources(graph, str(node_id)) - - -@router.get("/{instanceId}/upstream-paths/{node_id}") -@limiter.limit("60/minute") -def get_upstream_paths_saved( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - node_id: str = Path(..., description="Target node id"), - workflowId: str = Query(..., description="Workflow id whose saved graph is used"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return upstream paths using the persisted workflow graph (same payload as POST variant).""" - mandate_id = _validateInstanceAccess(instanceId, context) - if not workflowId: - raise HTTPException(status_code=400, detail=routeApiMsg("workflowId is required")) - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface - - iface = getGraphicalEditorInterface(context.user, mandate_id, featureInstanceId=instanceId) - wf = iface.getWorkflow(workflowId) - if not wf: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - graph = wf.get("graph") or {} - paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(node_id)) - return {"paths": paths} - - -@router.get("/{instanceId}/options/user.connection") -@limiter.limit("60/minute") -def get_user_connection_options( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - authority: Optional[str] = Query(None, description="Optional authority filter (e.g. 'msft', 'google', 'clickup', 'local')"), - activeOnly: bool = Query(True, description="If true (default), only ACTIVE connections are returned"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return current user's UserConnections as { options: [{ value, label }] }. - - Used by node parameters with frontendType='userConnection'. Optional - `authority` lets a node declare which provider it expects (e.g. SharePoint - nodes pass authority=msft so only Microsoft connections show up). - """ - _validateInstanceAccess(instanceId, context) - if not context.user: - raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) - from modules.interfaces.interfaceDbApp import getRootInterface - rootInterface = getRootInterface() - try: - connections = rootInterface.getUserConnections(str(context.user.id)) or [] - except Exception as e: - logger.error("get_user_connection_options: failed to load connections: %s", e, exc_info=True) - return {"options": []} - wanted = (authority or "").strip().lower() or None - options: List[Dict[str, str]] = [] - for conn in connections: - connStatus = getattr(conn, "status", None) - statusVal = connStatus.value if hasattr(connStatus, "value") else str(connStatus or "") - if activeOnly and statusVal.lower() != "active": - continue - connAuthority = getattr(conn, "authority", None) - authorityVal = (connAuthority.value if hasattr(connAuthority, "value") else str(connAuthority or "")).lower() - if wanted and authorityVal != wanted: - continue - username = getattr(conn, "externalUsername", "") or "" - email = getattr(conn, "externalEmail", "") or "" - connId = str(getattr(conn, "id", "") or "") - labelParts = [p for p in [username, email] if p] - label = " — ".join(labelParts) if labelParts else connId - if authorityVal: - label = f"[{authorityVal}] {label}" - value = f"connection:{authorityVal}:{username}" if authorityVal and username else connId - options.append({"value": value, "label": label}) - logger.info( - "graphicalEditor user.connection options: instanceId=%s authority=%s -> %d options", - instanceId, wanted, len(options), - ) - return {"options": options} - - -@router.get("/{instanceId}/options/feature.instance") -@limiter.limit("60/minute") -def get_feature_instance_options( - request: Request, - instanceId: str = Path(..., description="GraphicalEditor feature instance ID (workflow context)"), - featureCode: str = Query(..., description="Feature code to filter by (e.g. 'trustee', 'redmine', 'clickup')"), - enabledOnly: bool = Query(True, description="If true (default), only enabled feature instances are returned"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return mandate-scoped FeatureInstances for the given featureCode. - - Used by node parameters with frontendType='featureInstance' (e.g. Trustee - or Redmine nodes that need to bind to a specific tenant FeatureInstance). - Always restricted to the calling user's mandate (derived from the workflow - feature instance) so the picker never leaks foreign-mandate instances. - - Response: { options: [ { value: "", label: " ([code])" } ] } - """ - mandateId = _validateInstanceAccess(instanceId, context) - if not context.user: - raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) - code = (featureCode or "").strip().lower() - if not code: - raise HTTPException(status_code=400, detail=routeApiMsg("featureCode query parameter is required")) - if not mandateId: - return {"options": []} - - from modules.interfaces.interfaceDbApp import getRootInterface - rootInterface = getRootInterface() - try: - instances = rootInterface.getFeatureInstancesByMandate( - mandateId, enabledOnly=bool(enabledOnly) - ) or [] - except Exception as e: - logger.error( - "get_feature_instance_options: failed to load instances mandateId=%s: %s", - mandateId, e, exc_info=True, - ) - return {"options": []} - - options: List[Dict[str, str]] = [] - for fi in instances: - fiCode = (getattr(fi, "featureCode", "") or "").strip().lower() - if fiCode != code: - continue - fiId = str(getattr(fi, "id", "") or "") - if not fiId: - continue - rawLabel = getattr(fi, "label", None) or getattr(fi, "name", None) or fiId - options.append({"value": fiId, "label": f"{rawLabel} ({fiCode})"}) - - logger.info( - "graphicalEditor feature.instance options: instanceId=%s mandateId=%s " - "featureCode=%s enabledOnly=%s -> %d options", - instanceId, mandateId, code, enabledOnly, len(options), - ) - return {"options": options} - - -@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 - targetFeatureInstanceId: Optional[str] = None - if workflowId and not str(workflowId).startswith("transient-"): - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - workflow_for_envelope = iface.getWorkflow(workflowId) - if workflow_for_envelope: - targetFeatureInstanceId = workflow_for_envelope.get("targetFeatureInstanceId") - 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 - targetFeatureInstanceId = wf.get("targetFeatureInstanceId") - if not workflowId: - workflowId = f"transient-{uuid.uuid4().hex[:12]}" - logger.info("graphicalEditor execute: using transient workflowId=%s", workflowId) - - if targetFeatureInstanceId and targetFeatureInstanceId != instanceId: - _validateTargetInstance( - {"targetFeatureInstanceId": targetFeatureInstanceId}, - instanceId, - context, - ) - 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, - ) - - _wfLabel = None - if workflow_for_envelope: - _wfLabel = workflow_for_envelope.get("label") if isinstance(workflow_for_envelope, dict) else getattr(workflow_for_envelope, "label", 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, - label=_wfLabel, - targetFeatureInstanceId=targetFeatureInstanceId, - ) - 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"), - mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), - column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), - context: RequestContext = Depends(getRequestContext), -): - """List workflow templates with optional pagination. - - Supports the FormGeneratorTable backend pattern: - - default: paginated/filtered/sorted ``{items, pagination}`` response - - ``mode=filterValues&column=X``: distinct values for column X (cross-filtered) - - ``mode=ids``: all IDs matching current filters - """ - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - templates = iface.getTemplates(scope=scope) - - from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow - from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface - enrichRowsWithFkLabels(templates, AutoWorkflow, db=_getRootIface().db) - - if mode == "filterValues": - if not column: - raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory - return handleFilterValuesInMemory(templates, column, pagination) - - if mode == "ids": - from modules.dbHelpers.paginationHelpers import handleIdsInMemory - return handleIdsInMemory(templates, pagination) - - 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 -# ------------------------------------------------------------------------- - - -def _editorChatQueueId(workflowId: str) -> str: - """Deterministic SSE queue id for the editor chat (one active stream per workflow). - - Mirrors the workspace pattern (``workspace-{workflowId}``) so stop/cancel can - target the running task by workflowId without needing per-request handles. - """ - return f"ge-chat-{workflowId}" - - -def _getEditorChatInterface(context: RequestContext, mandateId: str, instanceId: str): - """Build the ChatObjects interface used to persist editor-chat messages.""" - from modules.interfaces import interfaceDbChat - return interfaceDbChat.getInterface( - context.user, - mandateId=mandateId, - featureInstanceId=instanceId, - ) - - -def _editorConversationHistoryFromPersisted(chatInterface, chatWorkflowId: str) -> List[Dict[str, Any]]: - """Load persisted ChatMessages for the editor chat and shape them as the - agent expects (``[{role, message}]``). Skips empty / system messages. - """ - try: - msgs = chatInterface.getMessages(chatWorkflowId) or [] - except Exception as e: - logger.warning("Editor chat: could not load persisted history for %s: %s", chatWorkflowId, e) - return [] - history: List[Dict[str, Any]] = [] - for m in msgs: - role = (getattr(m, "role", None) or (m.get("role") if isinstance(m, dict) else None) or "").strip() - text = (getattr(m, "message", None) or (m.get("message") if isinstance(m, dict) else None) or "").strip() - if not role or not text: - continue - if role not in ("user", "assistant", "system"): - continue - history.append({"role": role, "message": text}) - return history - - -@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, userLanguage? }"), - context: RequestContext = Depends(getRequestContext), -): - """AI chat endpoint for the editor with SSE streaming. Uses workflow tools to mutate the graph. - - Persistence: the chat is stored in the standard ``ChatWorkflow`` table linked - to this Automation2Workflow via ``ChatWorkflow.linkedWorkflowId``. The user - message is persisted before the agent starts; the assistant message after. - Conversation history is loaded server-side from this linked ChatWorkflow — - the client does not need to maintain it. - """ - 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") - fileIds = body.get("fileIds") or [] - dataSourceIds = body.get("dataSourceIds") or [] - featureDataSourceIds = body.get("featureDataSourceIds") or [] - - chatInterface = _getEditorChatInterface(context, mandateId, instanceId) - wfLabel = wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", None) - chatWorkflow = chatInterface.getOrCreateLinkedWorkflow( - featureInstanceId=instanceId, - linkedWorkflowId=workflowId, - name=wfLabel or f"Editor Chat ({workflowId})", - ) - chatWorkflowId = chatWorkflow.id if hasattr(chatWorkflow, "id") else chatWorkflow.get("id") - - conversationHistory = _editorConversationHistoryFromPersisted(chatInterface, chatWorkflowId) - - try: - chatInterface.createMessage({ - "workflowId": chatWorkflowId, - "role": "user", - "message": message, - "status": "first" if not conversationHistory else "step", - }) - except Exception as e: - logger.error("Editor chat: failed to persist user message: %s", e) - - from modules.serviceCenter.core.serviceStreaming import get_event_manager - sseEventManager = get_event_manager() - queueId = _editorChatQueueId(workflowId) - await sseEventManager.cancel_agent(queueId) - 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, - chatInterface=chatInterface, - chatWorkflowId=chatWorkflowId, - ) - ) - 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", - }, - ) - - -@router.get("/{instanceId}/{workflowId}/chat/messages") -@limiter.limit("120/minute") -def get_editor_chat_messages( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID (Automation2Workflow)"), - context: RequestContext = Depends(getRequestContext), -): - """Return persisted editor-chat messages for an Automation2Workflow. - - The chat is stored in ``ChatWorkflow`` with ``linkedWorkflowId == workflowId``; - if no chat has been started yet for this workflow we return an empty list (we - do NOT eagerly create one — the row is created on the first POST /chat/stream). - """ - mandateId = _validateInstanceAccess(instanceId, context) - chatInterface = _getEditorChatInterface(context, mandateId, instanceId) - chatWorkflow = chatInterface.getWorkflowByLink( - featureInstanceId=instanceId, - linkedWorkflowId=workflowId, - ) - if not chatWorkflow: - return JSONResponse({ - "chatWorkflowId": None, - "messages": [], - }) - - chatWorkflowId = chatWorkflow.id if hasattr(chatWorkflow, "id") else chatWorkflow.get("id") - rawMessages = chatInterface.getMessages(chatWorkflowId) or [] - - items: List[Dict[str, Any]] = [] - for m in rawMessages: - getter = (lambda key, default=None: getattr(m, key, default)) if not isinstance(m, dict) else (lambda key, default=None: m.get(key, default)) - role = (getter("role") or "").strip() - content = (getter("message") or "").strip() - if not role or not content: - continue - items.append({ - "id": getter("id"), - "role": role, - "content": content, - "timestamp": getter("publishedAt") or 0, - "sequenceNr": getter("sequenceNr") or 0, - }) - - items.sort(key=lambda x: (float(x.get("timestamp") or 0), int(x.get("sequenceNr") or 0))) - - return JSONResponse({ - "chatWorkflowId": chatWorkflowId, - "messages": items, - }) - - -@router.post("/{instanceId}/{workflowId}/chat/stop") -@limiter.limit("120/minute") -async def post_editor_chat_stop( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - context: RequestContext = Depends(getRequestContext), -): - """Stop a running editor-chat agent for the given workflow.""" - _validateInstanceAccess(instanceId, context) - from modules.serviceCenter.core.serviceStreaming import get_event_manager - sseEventManager = get_event_manager() - queueId = _editorChatQueueId(workflowId) - cancelled = await sseEventManager.cancel_agent(queueId) - await sseEventManager.emit_event(queueId, "stopped", { - "type": "stopped", - "workflowId": workflowId, - }) - logger.info("Editor chat stop requested for workflow %s, cancelled=%s", workflowId, cancelled) - return JSONResponse({"status": "stopped", "workflowId": workflowId, "cancelled": cancelled}) - - -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, - chatInterface=None, - chatWorkflowId: Optional[str] = None, -): - """Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue. - - Persists the assistant response to ``ChatMessage`` (linked via ``chatWorkflowId``) - on FINAL/ERROR. On cancellation any partial accumulated text is still saved so - the editor chat history reflects what the user actually saw on screen. - """ - assistantPersisted = False - - def _persistAssistant(text: str) -> None: - nonlocal assistantPersisted - if assistantPersisted or not chatInterface or not chatWorkflowId: - return - cleaned = (text or "").strip() - if not cleaned: - return - try: - chatInterface.createMessage({ - "workflowId": chatWorkflowId, - "role": "assistant", - "message": cleaned, - "status": "last", - }) - assistantPersisted = True - except Exception as msgErr: - logger.error("Editor chat: failed to persist assistant message: %s", msgErr) - - try: - from modules.serviceCenter import getService - from modules.serviceCenter.context import ServiceCenterContext - from modules.serviceCenter.services.serviceAgent.datamodelAgent import ( - AgentEventTypeEnum, AgentConfig, - ) - - 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 for the GraphicalEditor. " - "Your job is to MANAGE workflows for the user — create, rename, " - "import/export, edit the graph (nodes + connections) — but you must " - "NEVER execute a workflow or any of its actions. Even when the user " - "says 'create a workflow that sends an email', you build the graph " - "(add an email node, connect it) — you do NOT actually send an email." - "\n\nAvailable tools (all valid — use whichever the user's intent calls for):" - "\n Graph-mutating: readWorkflowGraph, listAvailableNodeTypes, " - "describeNodeType, addNode, removeNode, connectNodes, setNodeParameter, " - "listUpstreamPaths, bindNodeParameter, " - "autoLayoutWorkflow, validateGraph." - "\n Workflow lifecycle: createWorkflow (new empty workflow), " - "updateWorkflowMetadata (rename / change description / tags / activate), " - "createWorkflowFromFile (import .workflow.json from UDB), " - "exportWorkflowToFile (download envelope), deleteWorkflow (destructive — " - "ALWAYS confirm with the user before calling)." - "\n History: listWorkflowHistory, readWorkflowMessages." - "\n Connections (for parameters of frontendType='userConnection'): listConnections." - "\n\nIntent → tool mapping (do NOT improvise destructive paths):" - "\n • 'rename / umbenennen / call it X / nenne … um' → updateWorkflowMetadata({label: \"X\"})." - "\n • 'create empty workflow / new workflow / leeren Workflow' → createWorkflow({label: \"…\"})." - "\n • 'import / load from file' → createWorkflowFromFile({fileId: …})." - "\n • 'export / save to file / download' → exportWorkflowToFile()." - "\n • 'activate / deactivate' → updateWorkflowMetadata({active: true|false})." - "\n NEVER batch-call removeNode to 'rebuild' or 'rename' a workflow — that " - "destroys the user's work. removeNode is for removing ONE specific node the " - "user explicitly asked to delete." - "\n\nMandatory build sequence WHEN editing the graph:" - "\n1. readWorkflowGraph — understand current state." - "\n2. listAvailableNodeTypes — find candidate node ids." - "\n3. For EACH node type you plan to add: call describeNodeType(nodeType=...) " - "to learn its requiredParameters, allowedValues and ports. Never skip this " - "step — guessing parameters leaves the user with empty config cards." - "\n4. If any required parameter has frontendType='userConnection' (e.g. " - "email.checkEmail.connectionReference), call listConnections and pick the " - "connectionId that matches the user's intent (or ask the user if none clearly fits)." - "\n5. addNode with parameters={...} containing AT LEAST every requiredParameter " - "filled with a sensible value (use the user's request, the parameter " - "description, sane defaults, or — for required user-connection fields — " - "an actual connectionId). Do NOT pass position; the layout step handles it." - "\n6. connectNodes — wire the nodes consistent with port schemas from describeNodeType." - "\n6b. When a parameter must take data from an upstream node, call listUpstreamPaths(nodeId=target) " - "then bindNodeParameter(producerNodeId, path, parameterName) — do not rely on implicit wire fill." - "\n7. autoLayoutWorkflow — call exactly once as the LAST graph-mutating step so the " - "canvas shows a readable top-down layout instead of overlapping boxes." - "\n8. validateGraph — sanity check, then answer the user." - "\n\nIf a required parameter cannot be filled from the user's request and has " - "no safe default, ask the user once for that specific value (e.g. recipient " - "address, target language, prompt text) instead of leaving the field blank. " - "Respond concisely in the user's language and list what you changed." - ) - - editorConfig = AgentConfig( - toolSet="core", - excludeActionTools=True, - ) - - 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 [], - config=editorConfig, - 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): - _persistAssistant(event.content or accumulatedText) - break - - # Fallback: any streamed content not yet stored (cancellation path, no FINAL). - if not assistantPersisted and accumulatedText.strip(): - _persistAssistant(accumulatedText) - - await sseEventManager.emit_event(queueId, "complete", { - "type": "complete", - "workflowId": workflowId, - }) - - except asyncio.CancelledError: - logger.info("Editor chat agent task cancelled for workflow %s", workflowId) - # Save whatever the user already saw before cancelling so the next reload - # shows the same partial answer (matches workspace behaviour). - try: - _persistAssistant(accumulatedText if "accumulatedText" in locals() else "") - except Exception: - pass - 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)", - "kdrive": "kDrive", - "calendar": "Calendar", - "contact": "Contacts", - } - _serviceIcons = { - "sharepoint": "sharepoint", - "clickup": "folder", - "outlook": "mail", - "teams": "chat", - "onedrive": "cloud", - "drive": "cloud", - "gmail": "mail", - "files": "folder", - "kdrive": "cloud", - "calendar": "calendar", - "contact": "contact", - } - 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"), - mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), - column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), - context: RequestContext = Depends(getRequestContext), -): - """List all workflows for this feature instance. - - Supports the FormGeneratorTable backend pattern: - - default: paginated/filtered/sorted ``{items, pagination}`` response - - ``mode=filterValues&column=X``: distinct values for column X (cross-filtered) - - ``mode=ids``: all IDs matching current filters (for "select all") - """ - 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 "", - "lastStartedAt": last_started_at, - }) - - if mode == "filterValues": - if not column: - raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory - return handleFilterValuesInMemory(enriched, column, pagination) - - if mode == "ids": - from modules.dbHelpers.paginationHelpers import handleIdsInMemory - return handleIdsInMemory(enriched, pagination) - - 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) - _validateTargetInstance(body, 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) - existing = iface.getWorkflow(workflowId) - if not existing: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - merged = {**existing, **body} - _validateTargetInstance(merged, instanceId, context) - 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} - - -# ------------------------------------------------------------------------- -# Workflow File IO (versioned envelope export/import) -# ------------------------------------------------------------------------- - - -@router.post("/{instanceId}/workflows/import") -@limiter.limit("30/minute") -def import_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: dict = Body( - ..., - description=( - "{ envelope: , existingWorkflowId?: str, " - "fileId?: str } — supply EITHER the envelope inline OR a fileId of " - "a previously uploaded workflow file (.workflow.json)" - ), - ), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Import a workflow from a versioned-envelope file. - - Two input modes: - - ``envelope``: the parsed workflow-file payload (preferred for the agent) - - ``fileId``: the id of a previously uploaded ``.workflow.json`` in - Unified-Data-Bar (preferred for the UI "Import" modal) - - On success returns the created/updated workflow plus any non-fatal - warnings (e.g. dangling connection references). Imports are always - saved with ``active=False``. - """ - from modules.features.graphicalEditor._workflowFileSchema import WorkflowFileSchemaError - - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - - envelope = body.get("envelope") if isinstance(body, dict) else None - fileId = body.get("fileId") if isinstance(body, dict) else None - existingWorkflowId = body.get("existingWorkflowId") if isinstance(body, dict) else None - - if not envelope and fileId: - envelope = _loadEnvelopeFromFile(str(fileId), context) - - if not envelope: - raise HTTPException( - status_code=400, - detail=routeApiMsg("Body must contain 'envelope' or 'fileId'"), - ) - - try: - result = iface.importWorkflowFromDict(envelope, existingWorkflowId=existingWorkflowId) - except WorkflowFileSchemaError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) - - return result - - -@router.get("/{instanceId}/workflows/{workflowId}/export") -@limiter.limit("60/minute") -def export_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - download: bool = Query(False, description="If true, return as file download"), - context: RequestContext = Depends(getRequestContext), -): - """Export a workflow as a versioned-envelope JSON file. - - With ``download=true`` returns a streaming response with the canonical - ``.workflow.json`` filename so the browser triggers a save dialog. - Without it returns the envelope inline as JSON (used by the agent and by - the editor's "Save to file" → upload-to-UDB flow). - """ - from modules.features.graphicalEditor._workflowFileSchema import buildFileName - - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - envelope = iface.exportWorkflowToDict(workflowId) - if envelope is None: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - - if not download: - return {"envelope": envelope, "fileName": buildFileName(envelope.get("label", "workflow"))} - - fileName = buildFileName(envelope.get("label", "workflow")) - payload = json.dumps(envelope, ensure_ascii=False, indent=2).encode("utf-8") - return Response( - content=payload, - media_type="application/json", - headers={"Content-Disposition": f'attachment; filename="{fileName}"'}, - ) - - -def _loadEnvelopeFromFile(fileId: str, context: RequestContext) -> Optional[Dict[str, Any]]: - """Load and parse a ``.workflow.json`` file from the Unified-Data-Bar - by file id. Returns the parsed envelope dict or raises HTTPException.""" - try: - import modules.interfaces.interfaceDbManagement as interfaceDbManagement - mgmt = interfaceDbManagement.getInterface(context.user) - rawBytes = mgmt.getFileData(fileId) - except Exception as exc: - logger.warning("Failed to load workflow file %s: %s", fileId, exc) - raise HTTPException(status_code=404, detail=routeApiMsg(f"File {fileId} not found")) - - if not rawBytes: - raise HTTPException(status_code=404, detail=routeApiMsg(f"File {fileId} is empty")) - - try: - if isinstance(rawBytes, bytes): - text = rawBytes.decode("utf-8") - else: - text = str(rawBytes) - return json.loads(text) - except Exception as exc: - raise HTTPException( - status_code=400, - detail=routeApiMsg(f"File {fileId} is not valid JSON: {exc}"), - ) - - -# ------------------------------------------------------------------------- -# 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} - - -# ------------------------------------------------------------------------- -# 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, - ) - - -@router.post("/{instanceId}/tasks/{taskId}/cancel") -@limiter.limit("30/minute") -def cancel_pending_task_stop_run( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - taskId: str = Path(..., description="Human task ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Cancel a pending human task and stop the workflow run behind it.""" - 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")) - - wf_ids = {w.get("id") for w in iface.getWorkflows() if w.get("id")} - if task.get("workflowId") not in wf_ids: - 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")) - - run_id = task.get("runId") - - from modules.workflows.automation2.executionEngine import requestRunStop - - if run_id: - requestRunStop(run_id) - db_run = iface.getRun(run_id) - if db_run: - current = db_run.get("status") or "" - if current not in ("completed", "failed", "cancelled"): - iface.updateRun(run_id, status="cancelled") - - pending = iface.getTasks(runId=run_id, status="pending") - for t in pending: - tid = t.get("id") - if tid: - iface.updateTask(tid, status="cancelled") - else: - iface.updateTask(taskId, status="cancelled") - - return {"success": True, "runId": run_id, "taskId": taskId} - - -# ------------------------------------------------------------------------- -# 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), - } diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 9a6e2e26..002cb02d 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -110,6 +110,13 @@ def initBootstrap(db: DatabaseConnector) -> None: except Exception as e: logger.warning(f"Mandate retention purge failed: {e}") + # WorkflowAutomation bootstrap (system component, not auto-discovered) + try: + from modules.workflowAutomation.mainWorkflowAutomation import onBootstrap as _waBootstrap + _waBootstrap() + except Exception as _waBootErr: + logger.warning(f"onBootstrap hook for 'workflowAutomation' failed: {_waBootErr}") + # Let features run their own bootstrap logic via lifecycle hooks from modules.shared.featureDiscovery import loadFeatureMainModules for _fCode, _fMod in loadFeatureMainModules().items(): @@ -1610,7 +1617,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None: "resource.store.workspace", "resource.store.commcoach", "resource.store.trustee", - "resource.store.graphicalEditor", # DEPRECATED: will move with WorkflowAutomation code restructuring + "resource.store.workflowAutomation", ] storeRules = [] diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 6ebadaaf..023e07f3 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -1870,6 +1870,13 @@ class AppObjects: instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) + # 0-pre-wa. WorkflowAutomation cascade-delete (system component, not auto-discovered) + try: + from modules.workflowAutomation.mainWorkflowAutomation import onMandateDelete as _waDeleteHook + _waDeleteHook(mandateId, instances) + except Exception as _waDelErr: + logger.warning(f"onMandateDelete hook for 'workflowAutomation' failed: {_waDelErr}") + # 0-pre. Let features cascade-delete their own data via lifecycle hooks from modules.shared.featureDiscovery import loadFeatureMainModules for _fCode, _fMod in loadFeatureMainModules().items(): diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index 432769bd..39d95440 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -765,7 +765,7 @@ class ChatObjects: ) -> Optional[ChatWorkflow]: """Return the ChatWorkflow linked to (featureInstanceId, linkedWorkflowId), if any. - Used by editor-style features (e.g. GraphicalEditor AI editor chat) to + Used by editor-style features (e.g. WorkflowAutomation AI editor chat) to find the persisted chat for a specific external entity (Automation2Workflow). Falls under the same RBAC as ``getWorkflow``. """ diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 46289b7e..35a4008e 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -933,7 +933,7 @@ class ComponentObjects: If pagination is provided: PaginatedResult with items and metadata """ def _convertFileItems(files): - from modules.workflows.automation2.workflowArtifactVisibility import ( + from modules.workflowAutomation.engine.workflowArtifactVisibility import ( suppress_workflow_file_in_workspace_ui, ) diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py index 5f239c01..c947806c 100644 --- a/modules/interfaces/interfaceFeatures.py +++ b/modules/interfaces/interfaceFeatures.py @@ -271,7 +271,7 @@ class FeatureInterface: Copy feature-specific template workflows to a new instance. Loads TEMPLATE_WORKFLOWS from the feature module and creates - AutoWorkflow records in the graphicalEditor DB, scoped to + AutoWorkflow records in the workflowAutomation DB, scoped to (mandateId, instanceId). The placeholder {{featureInstanceId}} in graph parameters is replaced with the actual instanceId. @@ -321,14 +321,10 @@ class FeatureInterface: f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})" ) - geMod = mainModules.get("graphicalEditor") - onInstanceCreateHook = getattr(geMod, "onInstanceCreate", None) if geMod else None - if not onInstanceCreateHook: - logger.warning("_copyTemplateWorkflows: graphicalEditor.onInstanceCreate hook not available") - return 0 + from modules.workflowAutomation.mainWorkflowAutomation import onInstanceCreate as _waOnInstanceCreate try: - copied = onInstanceCreateHook(mandateId, instanceId, featureCode, templateWorkflows) + copied = _waOnInstanceCreate(mandateId, instanceId, featureCode, templateWorkflows) except Exception as e: logger.error( f"_copyTemplateWorkflows: onInstanceCreate hook failed for '{featureCode}': {e}", diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 8d886cfd..16429acb 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -204,16 +204,16 @@ TABLE_NAMESPACE = { # Automation - benutzer-eigen "AutomationDefinition": "automation", "AutomationTemplate": "automation", - # GraphicalEditor - Greenfield DB poweron_graphicaleditor (Auto-prefix models) - "AutoWorkflow": "feature.graphicalEditor", - "AutoVersion": "feature.graphicalEditor", - "AutoRun": "feature.graphicalEditor", - "AutoStepLog": "feature.graphicalEditor", - "AutoTask": "feature.graphicalEditor", + # WorkflowAutomation - Greenfield DB poweron_graphicaleditor (Auto-prefix models) + "AutoWorkflow": "system.workflowAutomation", + "AutoVersion": "system.workflowAutomation", + "AutoRun": "system.workflowAutomation", + "AutoStepLog": "system.workflowAutomation", + "AutoTask": "system.workflowAutomation", # Legacy aliases (backward compat) - "Automation2Workflow": "feature.graphicalEditor", - "Automation2WorkflowRun": "feature.graphicalEditor", - "Automation2HumanTask": "feature.graphicalEditor", + "Automation2Workflow": "system.workflowAutomation", + "Automation2WorkflowRun": "system.workflowAutomation", + "Automation2HumanTask": "system.workflowAutomation", # Knowledge Store - benutzer-eigen "FileContentIndex": "knowledge", "ContentChunk": "knowledge", diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/interfaces/interfaceWorkflowAutomation.py similarity index 91% rename from modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py rename to modules/interfaces/interfaceWorkflowAutomation.py index 092389c6..6d192451 100644 --- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py +++ b/modules/interfaces/interfaceWorkflowAutomation.py @@ -1,8 +1,14 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Interface for GraphicalEditor feature - Workflows, Runs, Human Tasks. -Uses PostgreSQL poweron_graphicaleditor database (Greenfield). +Interface for WorkflowAutomation system component - Workflows, Runs, Human Tasks. +Uses PostgreSQL poweron_graphicaleditor database. + +Architecture note: This interface (L4) uses lazy imports from +workflowAutomation.editor (L5) for export/import operations. +This is a documented exception — workflowAutomation is a system component +whose editor module provides pure transformation functions with no +upward dependencies. """ import base64 @@ -47,33 +53,36 @@ from modules.datamodels.datamodelWorkflowAutomation import ( AutoStepLog, AutoTask, ) -from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG from modules.dbHelpers.dbRegistry import registerDatabase logger = logging.getLogger(__name__) -graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE -registerDatabase(graphicalEditorDatabase) -_CALLBACK_WORKFLOW_CHANGED = "graphicalEditor.workflow.changed" +workflowAutomationDatabase = GRAPHICAL_EDITOR_DATABASE +registerDatabase(workflowAutomationDatabase) +_CALLBACK_WORKFLOW_CHANGED = "workflowAutomation.workflow.changed" -def getGraphicalEditorInterface( +def _invocationsSyncedWithGraph(graph, invocations): + """Lazy-load entryPoints to avoid L4->L5 top-level import.""" + from modules.workflowAutomation.editor.entryPoints import invocations_synced_with_graph + return invocations_synced_with_graph(graph, invocations) + + +def _getWorkflowAutomationInterface( currentUser: User, mandateId: str, featureInstanceId: str, -) -> "GraphicalEditorObjects": - """Factory for GraphicalEditor interface with user context.""" - return GraphicalEditorObjects( +) -> "WorkflowAutomationObjects": + """Factory for WorkflowAutomation interface with user context.""" + return WorkflowAutomationObjects( currentUser=currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId, ) -# Backward-compatible alias used by workflows/automation2/ execution engine -getAutomation2Interface = getGraphicalEditorInterface def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: @@ -82,7 +91,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: Used by the scheduler to register cron jobs. Does not filter by mandate/instance. """ dbHost = APP_CONFIG.get("DB_HOST", "localhost") - dbDatabase = graphicalEditorDatabase + dbDatabase = workflowAutomationDatabase dbUser = APP_CONFIG.get("DB_USER") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD") dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) @@ -95,7 +104,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: userId=None, ) if not connector._ensureTableExists(AutoWorkflow): - logger.warning("GraphicalEditor schedule: table AutoWorkflow does not exist yet") + logger.warning("WorkflowAutomation schedule: table AutoWorkflow does not exist yet") return [] records = connector.getRecordset( AutoWorkflow, @@ -107,7 +116,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: if r.get("active") is False: continue wf = dict(r) - wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations")) + wf["invocations"] = _invocationsSyncedWithGraph(wf.get("graph") or {}, wf.get("invocations")) invocations = wf.get("invocations") or [] primary = invocations[0] if invocations else {} if not isinstance(primary, dict): @@ -142,15 +151,15 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: "workflow": wf, }) logger.info( - "GraphicalEditor schedule: DB has %d workflow(s), %d active with trigger.schedule+cron", + "WorkflowAutomation schedule: DB has %d workflow(s), %d active with trigger.schedule+cron", raw_count, len(result), ) return result -class GraphicalEditorObjects: - """Interface for GraphicalEditor database operations (Greenfield DB).""" +class WorkflowAutomationObjects: + """Interface for WorkflowAutomation database operations (poweron_graphicaleditor DB).""" def __init__( self, @@ -167,9 +176,9 @@ class GraphicalEditorObjects: self.db.updateContext(self.userId) def _init_db(self): - """Initialize database connection to poweron_graphicaleditor (Greenfield).""" + """Initialize database connection to poweron_graphicaleditor.""" dbHost = APP_CONFIG.get("DB_HOST", "localhost") - dbDatabase = graphicalEditorDatabase + dbDatabase = workflowAutomationDatabase dbUser = APP_CONFIG.get("DB_USER") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD") dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) @@ -181,7 +190,7 @@ class GraphicalEditorObjects: dbPort=dbPort, userId=self.userId, ) - logger.debug("GraphicalEditor database initialized for user %s", self.userId) + logger.debug("WorkflowAutomation database initialized for user %s", self.userId) # ------------------------------------------------------------------------- # Workflow CRUD @@ -202,7 +211,7 @@ class GraphicalEditorObjects: ) rows = [dict(r) for r in records] if records else [] for wf in rows: - wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations")) + wf["invocations"] = _invocationsSyncedWithGraph(wf.get("graph") or {}, wf.get("invocations")) return rows def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]: @@ -219,7 +228,7 @@ class GraphicalEditorObjects: if not records: return None wf = dict(records[0]) - wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations")) + wf["invocations"] = _invocationsSyncedWithGraph(wf.get("graph") or {}, wf.get("invocations")) return wf def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]: @@ -232,10 +241,10 @@ class GraphicalEditorObjects: data["targetFeatureInstanceId"] = self.featureInstanceId if "active" not in data or data.get("active") is None: data["active"] = True - data["invocations"] = invocations_synced_with_graph(data.get("graph") or {}, data.get("invocations")) + data["invocations"] = _invocationsSyncedWithGraph(data.get("graph") or {}, data.get("invocations")) created = self.db.recordCreate(AutoWorkflow, data) out = dict(created) - out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations")) + out["invocations"] = _invocationsSyncedWithGraph(out.get("graph") or {}, out.get("invocations")) try: from modules.shared.callbackRegistry import callbackRegistry callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED) @@ -255,10 +264,10 @@ class GraphicalEditorObjects: if not isinstance(g, dict): g = {} inv = data["invocations"] if "invocations" in data else existing.get("invocations") - data["invocations"] = invocations_synced_with_graph(g, inv) + data["invocations"] = _invocationsSyncedWithGraph(g, inv) updated = self.db.recordModify(AutoWorkflow, workflowId, data) out = dict(updated) - out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations")) + out["invocations"] = _invocationsSyncedWithGraph(out.get("graph") or {}, out.get("invocations")) try: from modules.shared.callbackRegistry import callbackRegistry callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED) @@ -683,7 +692,7 @@ class GraphicalEditorObjects: envelope) and can be JSON-serialized as-is. Returns ``None`` if the workflow does not exist for this mandate. """ - from modules.features.graphicalEditor._workflowFileSchema import buildFileFromWorkflow + from modules.workflowAutomation.editor._workflowFileSchema import buildFileFromWorkflow wf = self.getWorkflow(workflowId) if not wf: @@ -702,11 +711,11 @@ class GraphicalEditorObjects: ``existingWorkflowId`` is given. Imports are always saved with ``active=False`` so operators can review before scheduling. """ - from modules.features.graphicalEditor._workflowFileSchema import ( + from modules.workflowAutomation.editor._workflowFileSchema import ( envelopeToWorkflowData, validateFileEnvelope, ) - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES knownTypes = [n.get("id") for n in STATIC_NODE_TYPES if isinstance(n, dict) and n.get("id")] normalizedEnvelope, warnings = validateFileEnvelope(envelope, knownNodeTypes=knownTypes) @@ -728,6 +737,3 @@ class GraphicalEditorObjects: created = self.createWorkflow(data) return {"workflow": created, "warnings": warnings, "created": True} - -# Backward-compatible alias -Automation2Objects = GraphicalEditorObjects diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index b3072edc..350d8311 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -913,11 +913,11 @@ def _syncInstanceWorkflows( if not templateWorkflows: return SyncWorkflowsResult(added=0, skipped=0, total=0) - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface from modules.security.rootAccess import getRootUser rootUser = getRootUser() - geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId) + geInterface = _getWorkflowAutomationInterface(rootUser, mandateId, instanceId) existingWorkflows = geInterface.getWorkflows() or [] existingSourceIds = set() diff --git a/modules/routes/routeAutomationWorkspace.py b/modules/routes/routeAutomationWorkspace.py deleted file mode 100644 index a93fff70..00000000 --- a/modules/routes/routeAutomationWorkspace.py +++ /dev/null @@ -1,309 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -User-facing Automation Workspace API. - -Lists workflow runs the user can access (via FeatureAccess on -targetFeatureInstanceId) and provides detail views with step logs -and linked files. Designed for the "Workspace" tab under -Nutzung > Automation. -""" - -import logging -import math -from functools import partial -from typing import Optional - -from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException -from slowapi import Limiter -from slowapi.util import get_remote_address - -from modules.auth.authentication import getRequestContext, RequestContext -from modules.connectors.connectorDbPostgre import DatabaseConnector -from modules.shared.configuration import APP_CONFIG -from modules.datamodels.datamodelWorkflowAutomation import ( - AutoRun, - AutoStepLog, - AutoWorkflow, - GRAPHICAL_EDITOR_DATABASE, -) -from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui -from modules.shared.i18nRegistry import apiRouteContext - -routeApiMsg = apiRouteContext("routeAutomationWorkspace") -logger = logging.getLogger(__name__) -limiter = Limiter(key_func=get_remote_address) - -router = APIRouter(prefix="/api/automations/runs", tags=["AutomationWorkspace"]) - - -def _getDb() -> DatabaseConnector: - return DatabaseConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=GRAPHICAL_EDITOR_DATABASE, - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId=None, - ) - - -def _getUserAccessibleInstanceIds(userId: str) -> list[str]: - """Return all featureInstanceIds the user has enabled FeatureAccess for.""" - from modules.interfaces.interfaceDbApp import getRootInterface - rootIface = getRootInterface() - allAccess = rootIface.getFeatureAccessesForUser(userId) or [] - return [ - a.featureInstanceId - for a in allAccess - if a.featureInstanceId and a.enabled - ] - - -_FILE_REF_KEYS = ("fileId", "documentId", "fileIds", "documents") - - -def _extractFileIdsFromValue(value, accumulator: set[str]) -> None: - """Recursively scan a value (dict/list/str) for file id references.""" - if isinstance(value, dict): - for key, sub in value.items(): - if key in _FILE_REF_KEYS: - _collectFileIdsFromRef(sub, accumulator) - else: - _extractFileIdsFromValue(sub, accumulator) - elif isinstance(value, list): - for item in value: - _extractFileIdsFromValue(item, accumulator) - - -def _collectFileIdsFromRef(val, accumulator: set[str]) -> None: - """Add file ids from a value located under a known file-reference key.""" - if isinstance(val, str) and val: - accumulator.add(val) - elif isinstance(val, list): - for v in val: - if isinstance(v, str) and v: - accumulator.add(v) - elif isinstance(v, dict) and v.get("id"): - accumulator.add(v["id"]) - elif isinstance(val, dict) and val.get("id"): - accumulator.add(val["id"]) - - -@router.get("") -@limiter.limit("60/minute") -def listWorkspaceRuns( - request: Request, - scope: str = Query("mine", description="mine = own runs, mandate = all accessible"), - status: Optional[str] = Query(None, description="Filter by run status"), - targetInstanceId: Optional[str] = Query(None, description="Filter by targetFeatureInstanceId"), - workflowId: Optional[str] = Query(None, description="Filter by workflow"), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """List workflow runs visible to the user. - - scope=mine: only runs owned by the user. - scope=mandate: all runs where the user has FeatureAccess on the - workflow's targetFeatureInstanceId. - """ - db = _getDb() - if not db._ensureTableExists(AutoRun): - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - - userId = str(context.user.id) if context.user else None - if not userId: - raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) - - accessibleInstanceIds = _getUserAccessibleInstanceIds(userId) - if not accessibleInstanceIds: - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - - if not db._ensureTableExists(AutoWorkflow): - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - - wfFilter: dict = {} - if targetInstanceId: - if targetInstanceId not in accessibleInstanceIds: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to target instance")) - wfFilter["targetFeatureInstanceId"] = targetInstanceId - workflows = db.getRecordset(AutoWorkflow, recordFilter=wfFilter or None) or [] - - visibleWfIds: set[str] = set() - wfMap: dict = {} - for wf in workflows: - wfDict = dict(wf) - tid = wfDict.get("targetFeatureInstanceId") or wfDict.get("featureInstanceId") - if tid and tid in accessibleInstanceIds: - wfId = wfDict.get("id") - if wfId: - visibleWfIds.add(wfId) - wfMap[wfId] = wfDict - - if workflowId: - if workflowId not in visibleWfIds: - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - visibleWfIds = {workflowId} - - if not visibleWfIds: - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - - allRuns = db.getRecordset(AutoRun, recordFilter={}) or [] - filtered = [] - for r in allRuns: - row = dict(r) - if row.get("workflowId") not in visibleWfIds: - continue - if scope == "mine" and row.get("ownerId") != userId: - continue - if status and row.get("status") != status: - continue - filtered.append(row) - - filtered.sort( - key=lambda x: x.get("startedAt") or x.get("sysCreatedAt") or 0, - reverse=True, - ) - total = len(filtered) - page = filtered[offset: offset + limit] - - from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels - - for row in page: - wf = wfMap.get(row.get("workflowId"), {}) - row["workflowLabel"] = row.get("label") or wf.get("label") or row.get("workflowId", "") - row["targetFeatureInstanceId"] = wf.get("targetFeatureInstanceId") or wf.get("featureInstanceId") - - enrichRowsWithFkLabels( - page, - db=db, - labelResolvers={ - "mandateId": partial(resolveMandateLabels, db), - "targetFeatureInstanceId": partial(resolveInstanceLabels, db), - }, - ) - for row in page: - row["targetInstanceLabel"] = row.pop("targetFeatureInstanceIdLabel", None) - row["mandateLabel"] = row.pop("mandateIdLabel", None) - - return {"runs": page, "total": total, "limit": limit, "offset": offset} - - -@router.get("/{runId}/detail") -@limiter.limit("60/minute") -def getWorkspaceRunDetail( - request: Request, - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Get full detail for a single run: metadata, step logs, linked files.""" - db = _getDb() - userId = str(context.user.id) if context.user else None - if not userId: - raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) - - if not db._ensureTableExists(AutoRun): - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - - runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) - if not runs: - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - run = dict(runs[0]) - - wfId = run.get("workflowId") - workflow: dict = {} - if wfId and db._ensureTableExists(AutoWorkflow): - wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfId}) - if wfs: - workflow = dict(wfs[0]) - - tid = workflow.get("targetFeatureInstanceId") or workflow.get("featureInstanceId") - accessibleIds = _getUserAccessibleInstanceIds(userId) - isOwner = run.get("ownerId") == userId - - if not isOwner and (not tid or tid not in accessibleIds) and not context.isPlatformAdmin: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) - - steps: list = [] - if db._ensureTableExists(AutoStepLog): - stepRecords = db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or [] - steps = [dict(s) for s in stepRecords] - steps.sort(key=lambda s: s.get("startedAt") or 0) - - allFileIds: set[str] = set() - perStepFileIds: list[tuple[set[str], set[str]]] = [] - for step in steps: - inputIds: set[str] = set() - outputIds: set[str] = set() - _extractFileIdsFromValue(step.get("inputSnapshot") or {}, inputIds) - _extractFileIdsFromValue(step.get("output") or {}, outputIds) - perStepFileIds.append((inputIds, outputIds)) - allFileIds.update(inputIds) - allFileIds.update(outputIds) - - nodeOutputs = run.get("nodeOutputs") or {} - runLevelIds: set[str] = set() - _extractFileIdsFromValue(nodeOutputs, runLevelIds) - allFileIds.update(runLevelIds) - - fileMetaById: dict[str, dict] = {} - try: - from modules.datamodels.datamodelFiles import FileItem - from modules.interfaces.interfaceDbManagement import ComponentObjects - mgmtDb = ComponentObjects().db - if mgmtDb._ensureTableExists(FileItem): - for fid in allFileIds: - try: - rec = mgmtDb.getRecord(FileItem, fid) - if rec: - recDict = dict(rec) - fileMetaById[fid] = { - "id": fid, - "fileName": recDict.get("fileName") or recDict.get("name"), - } - except Exception: - pass - except Exception as e: - logger.warning("getWorkspaceRunDetail: file lookup failed: %s", e) - - def _resolveFileList(ids: set[str]) -> list[dict]: - rows = [dict(fileMetaById[fid]) for fid in ids if fid in fileMetaById] - return [m for m in rows if not suppress_workflow_file_in_workspace_ui(m)] - - assignedFileIds: set[str] = set() - for step, (inputIds, outputIds) in zip(steps, perStepFileIds): - step["inputFiles"] = _resolveFileList(inputIds) - step["outputFiles"] = _resolveFileList(outputIds) - assignedFileIds.update(inputIds) - assignedFileIds.update(outputIds) - - unassignedFiles = _resolveFileList(allFileIds - assignedFileIds) - allFiles = _resolveFileList(allFileIds) - - run["workflowLabel"] = run.get("label") or workflow.get("label") or wfId - run["targetFeatureInstanceId"] = tid - - targetInstanceLabel = None - if tid: - try: - from modules.dbHelpers.fkLabelResolver import resolveInstanceLabels - labelMap = resolveInstanceLabels(db, [tid]) - targetInstanceLabel = labelMap.get(tid) - except Exception: - pass - run["targetInstanceLabel"] = targetInstanceLabel - - return { - "run": run, - "workflow": { - "id": workflow.get("id"), - "label": workflow.get("label"), - "targetFeatureInstanceId": tid, - "featureInstanceId": workflow.get("featureInstanceId"), - "tags": workflow.get("tags", []), - } if workflow else None, - "steps": steps, - "files": allFiles, - "unassignedFiles": unassignedFiles, - } diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 217dfa14..8529206b 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -833,7 +833,7 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]: except Exception as e: logger.debug(f"integrations-overview billing stats: {e}") - # Workflow metrics (same logic as routeWorkflowDashboard.get_workflow_metrics) + # Workflow metrics (same logic as routeWorkflowAutomation.get_workflow_metrics) try: from modules.shared.configuration import APP_CONFIG from modules.connectors.connectorDbPostgre import DatabaseConnector diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py index 6ce6fb21..ee5d4ac1 100644 --- a/modules/routes/routeWorkflowAutomation.py +++ b/modules/routes/routeWorkflowAutomation.py @@ -4,8 +4,7 @@ Mandatsweite WorkflowAutomation API. System-level API for workflows, runs, tasks — scoped by mandate membership, -not by graphicalEditor FeatureInstance. Parallel to the legacy per-instance -API in routeFeatureGraphicalEditor.py during the migration period. +not by FeatureInstance. Uses mandate-scoped RBAC. RBAC model: - Read: mandate membership (user sees workflows in own mandates) @@ -13,12 +12,11 @@ RBAC model: - isPlatformAdmin bypasses all checks """ -import json import logging -import time +import uuid from typing import Optional, List, Dict, Any -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request from slowapi import Limiter from slowapi.util import get_remote_address @@ -26,12 +24,16 @@ from modules.auth.authentication import getRequestContext, RequestContext from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.datamodels.datamodelWorkflowAutomation import ( AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, - GRAPHICAL_EDITOR_DATABASE, ) -from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict -from modules.interfaces.interfaceDbApp import getRootInterface -from modules.shared.configuration import APP_CONFIG -from modules.shared.i18nRegistry import apiRouteContext +from modules.shared.i18nRegistry import apiRouteContext, resolveText +from modules.shared.workflowAutomationHelpers import ( + _getWorkflowAutomationDb, + _validateWorkflowAccess, + _scopedWorkflowFilter, + _scopedRunFilter, + _parsePaginationOr400, + _cascadeDeleteWorkflow, +) routeApiMsg = apiRouteContext("routeWorkflowAutomation") @@ -41,169 +43,6 @@ limiter = Limiter(key_func=get_remote_address) router = APIRouter(prefix="/api/workflow-automation", tags=["WorkflowAutomation"]) -# --------------------------------------------------------------------------- -# DB + RBAC helpers -# --------------------------------------------------------------------------- - -def _getDb() -> DatabaseConnector: - return DatabaseConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=GRAPHICAL_EDITOR_DATABASE, - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId=None, - ) - - -def _getUserMandateIds(userId: str) -> List[str]: - rootIface = getRootInterface() - memberships = rootIface.getUserMandates(userId) - return [um.mandateId for um in memberships if um.mandateId and um.enabled] - - -def _getAdminMandateIds(userId: str, mandateIds: List[str]) -> List[str]: - if not mandateIds: - return [] - rootIface = getRootInterface() - from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole - - memberships = rootIface.db.getRecordset( - UserMandate, - recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True}, - ) - if not memberships: - return [] - - umIdToMandateId: Dict[str, str] = {} - for m in memberships: - row = m if isinstance(m, dict) else m.__dict__ - um_id = row.get("id") - mid = row.get("mandateId") - if um_id and mid: - umIdToMandateId[str(um_id)] = str(mid) - - userMandateIds = list(umIdToMandateId.keys()) - allRoles = rootIface.db.getRecordset( - UserMandateRole, - recordFilter={"userMandateId": userMandateIds}, - ) - if not allRoles: - return [] - - roleIds: set = set() - roleToMandate: Dict[str, set] = {} - for r in allRoles: - row = r if isinstance(r, dict) else r.__dict__ - rid = row.get("roleId") - um_id = row.get("userMandateId") - mid = umIdToMandateId.get(str(um_id)) if um_id else None - if rid and mid: - roleIds.add(rid) - roleToMandate.setdefault(rid, set()).add(mid) - - if not roleIds: - return [] - - from modules.datamodels.datamodelRbac import Role - roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)}) - adminMandates: set = set() - for role in (roleRecords or []): - row = role if isinstance(role, dict) else role.__dict__ - rid = row.get("id") - if not rid or rid not in roleToMandate: - continue - if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"): - adminMandates.update(roleToMandate[rid]) - - return [mid for mid in mandateIds if mid in adminMandates] - - -def _validateWorkflowAccess( - context: RequestContext, - workflow: Optional[Dict[str, Any]], - action: str = "read", -) -> None: - """Validate access to a workflow based on mandate membership + admin status. - - Actions: 'read' (mandate member), 'write'/'execute'/'delete' (mandate admin or platform admin). - Raises HTTPException(403) on denial. - """ - if context.isPlatformAdmin: - return - - userId = str(context.user.id) if context.user else None - if not userId: - raise HTTPException(status_code=403, detail="Authentication required") - - if workflow is None: - raise HTTPException(status_code=404, detail="Workflow not found") - - wfMandateId = workflow.get("mandateId") or "" - if not wfMandateId: - if action == "read": - return - raise HTTPException(status_code=403, detail="Workflow has no mandate — admin only") - - userMandateIds = _getUserMandateIds(userId) - if wfMandateId not in userMandateIds: - raise HTTPException(status_code=403, detail="Not a member of the workflow's mandate") - - if action == "read": - return - - adminMandateIds = _getAdminMandateIds(userId, [wfMandateId]) - if wfMandateId not in adminMandateIds: - raise HTTPException( - status_code=403, - detail=f"Mandate admin required for '{action}' on workflows", - ) - - -def _scopedWorkflowFilter(context: RequestContext) -> Optional[Dict[str, Any]]: - """Build DB filter for listing workflows: mandate-scoped for members, None for sysadmin.""" - if context.isPlatformAdmin: - return None - - userId = str(context.user.id) if context.user else None - if not userId: - return {"mandateId": "__impossible__"} - - mandateIds = _getUserMandateIds(userId) - if mandateIds: - return {"mandateId": mandateIds} - return {"mandateId": "__impossible__"} - - -def _scopedRunFilter(context: RequestContext) -> Optional[Dict[str, Any]]: - """Build DB filter for listing runs: admin sees mandate runs, user sees own.""" - if context.isPlatformAdmin: - return None - - userId = str(context.user.id) if context.user else None - if not userId: - return {"ownerId": "__impossible__"} - - mandateIds = _getUserMandateIds(userId) - adminMandateIds = _getAdminMandateIds(userId, mandateIds) - - if adminMandateIds: - return {"mandateId": adminMandateIds} - return {"ownerId": userId} - - -def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]: - if not pagination: - return None - try: - d = json.loads(pagination) - except json.JSONDecodeError: - raise HTTPException(status_code=400, detail="Invalid pagination JSON") - if not d: - return None - return normalize_pagination_dict(d) - - # --------------------------------------------------------------------------- # Workflow CRUD # --------------------------------------------------------------------------- @@ -214,7 +53,7 @@ async def _listWorkflows( pagination: Optional[str] = Query(default=None), mandateId: Optional[str] = Query(default=None), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) scopeFilter = _scopedWorkflowFilter(request) @@ -225,7 +64,7 @@ async def _listWorkflows( elif mandateId and scopeFilter is None: scopeFilter = {"mandateId": mandateId} - params = _parsePagination(pagination) + params = _parsePaginationOr400(pagination) records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter, pagination=params) total = db.getRecordCount(AutoWorkflow, recordFilter=scopeFilter) if params else len(records or []) return {"items": records or [], "total": total} @@ -238,7 +77,7 @@ async def _getWorkflow( workflowId: str, request: RequestContext = Depends(getRequestContext), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) @@ -261,10 +100,9 @@ async def _createWorkflow( _validateWorkflowAccess(request, {"mandateId": mandateId}, "write") - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) - import uuid data = {**body, "id": str(uuid.uuid4())} if request.user: data.setdefault("runAsPrincipal", str(request.user.id)) @@ -280,7 +118,7 @@ async def _updateWorkflow( request: RequestContext = Depends(getRequestContext), body: Dict[str, Any] = {}, ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) @@ -296,22 +134,12 @@ async def _deleteWorkflow( workflowId: str, request: RequestContext = Depends(getRequestContext), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) _validateWorkflowAccess(request, wf, "delete") - - for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId}) or []: - db.recordDelete(AutoVersion, v.get("id")) - for run in db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []: - runId = run.get("id") - for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: - db.recordDelete(AutoStepLog, sl.get("id")) - db.recordDelete(AutoRun, runId) - for task in db.getRecordset(AutoTask, recordFilter={"workflowId": workflowId}) or []: - db.recordDelete(AutoTask, task.get("id")) - db.recordDelete(AutoWorkflow, workflowId) + _cascadeDeleteWorkflow(db, workflowId) return {"deleted": True, "workflowId": workflowId} finally: db.close() @@ -328,7 +156,7 @@ async def _listRuns( mandateId: Optional[str] = Query(default=None), workflowId: Optional[str] = Query(default=None), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoRun) scopeFilter = _scopedRunFilter(request) @@ -342,7 +170,7 @@ async def _listRuns( if workflowId: scopeFilter = {**(scopeFilter or {}), "workflowId": workflowId} - params = _parsePagination(pagination) + params = _parsePaginationOr400(pagination) records = db.getRecordset(AutoRun, recordFilter=scopeFilter, pagination=params) total = db.getRecordCount(AutoRun, recordFilter=scopeFilter) if params else len(records or []) return {"items": records or [], "total": total} @@ -355,7 +183,7 @@ async def _getRun( runId: str, request: RequestContext = Depends(getRequestContext), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoRun) run = db.getRecord(AutoRun, runId) @@ -381,7 +209,7 @@ async def _listTasks( pagination: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoTask) scopeFilter: Optional[Dict[str, Any]] = None @@ -395,7 +223,7 @@ async def _listTasks( if status: scopeFilter = {**(scopeFilter or {}), "status": status} - params = _parsePagination(pagination) + params = _parsePaginationOr400(pagination) records = db.getRecordset(AutoTask, recordFilter=scopeFilter, pagination=params) total = db.getRecordCount(AutoTask, recordFilter=scopeFilter) if params else len(records or []) return {"items": records or [], "total": total} @@ -412,7 +240,7 @@ async def _listVersions( workflowId: str, request: RequestContext = Depends(getRequestContext), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) @@ -434,7 +262,7 @@ async def _listStepLogs( runId: str, request: RequestContext = Depends(getRequestContext), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoRun) run = db.getRecord(AutoRun, runId) @@ -451,3 +279,1559 @@ async def _listStepLogs( return {"items": steps or []} finally: db.close() + + +# --------------------------------------------------------------------------- +# Internal helpers (mandate resolution, connector adapter) +# --------------------------------------------------------------------------- + +def _resolveInstanceIdForWorkflow(db: DatabaseConnector, workflowId: str) -> Optional[str]: + """Look up the featureInstanceId stored on the workflow record.""" + if not workflowId: + return None + wf = db.getRecord(AutoWorkflow, workflowId) if db._ensureTableExists(AutoWorkflow) else None + if not wf: + return None + return wf.get("featureInstanceId") or wf.get("targetFeatureInstanceId") + + +def _resolveMandateIdForWorkflow(db: DatabaseConnector, workflowId: str) -> Optional[str]: + """Look up the mandateId stored on the workflow record.""" + if not workflowId: + return None + wf = db.getRecord(AutoWorkflow, workflowId) if db._ensureTableExists(AutoWorkflow) else None + if not wf: + return None + return wf.get("mandateId") + + +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) + + +def _getWorkflowAutomationInterface(context: RequestContext, mandateId: str, instanceId: str): + """Build the WorkflowAutomation interface for template / import-export operations.""" + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface as _ifaceFactory + return _ifaceFactory(context.user, mandateId, instanceId) + + +def _loadEnvelopeFromFile(fileId: str, context: RequestContext) -> Optional[Dict[str, Any]]: + """Load and parse a ``.workflow.json`` file from the Unified-Data-Bar by file id.""" + try: + import modules.interfaces.interfaceDbManagement as interfaceDbManagement + mgmt = interfaceDbManagement.getInterface(context.user) + rawBytes = mgmt.getFileData(fileId) + except Exception as exc: + logger.warning("Failed to load workflow file %s: %s", fileId, exc) + raise HTTPException(status_code=404, detail=routeApiMsg(f"File {fileId} not found")) + + if not rawBytes: + raise HTTPException(status_code=404, detail=routeApiMsg(f"File {fileId} is empty")) + + try: + if isinstance(rawBytes, bytes): + text = rawBytes.decode("utf-8") + else: + text = str(rawBytes) + return json.loads(text) + except Exception as exc: + raise HTTPException( + status_code=400, + detail=routeApiMsg(f"File {fileId} is not valid JSON: {exc}"), + ) + + +def _getUserAccessibleInstanceIds(userId: str) -> List[str]: + """Return all featureInstanceIds the user has enabled FeatureAccess for.""" + rootIface = getRootInterface() + allAccess = rootIface.getFeatureAccessesForUser(userId) or [] + return [ + a.featureInstanceId + for a in allAccess + if a.featureInstanceId and a.enabled + ] + + +# --------------------------------------------------------------------------- +# Group 4 — Templates +# --------------------------------------------------------------------------- + +@router.get("/templates") +@limiter.limit("60/minute") +def _listTemplates( + request: Request, + scope: Optional[str] = Query(None, description="Filter by scope: user, instance, mandate, system"), + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), + mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), + column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), + mandateId: Optional[str] = Query(None, description="Mandate ID to scope templates"), + context: RequestContext = Depends(getRequestContext), +): + """List workflow templates with optional pagination. + + Supports the FormGeneratorTable backend pattern: + - default: paginated/filtered/sorted ``{items, pagination}`` response + - ``mode=filterValues&column=X``: distinct values for column X (cross-filtered) + - ``mode=ids``: all IDs matching current filters + """ + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + effectiveMandateId = mandateId or (userMandateIds[0] if userMandateIds else None) + if not effectiveMandateId and not context.isPlatformAdmin: + return {"templates": []} + + instanceId = None + if effectiveMandateId: + db = _getWorkflowAutomationDb() + try: + if db._ensureTableExists(AutoWorkflow): + wfs = db.getRecordset(AutoWorkflow, recordFilter={"mandateId": effectiveMandateId}) + for w in (wfs or []): + fid = w.get("featureInstanceId") + if fid: + instanceId = fid + break + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, effectiveMandateId or "", instanceId or "") + templates = iface.getTemplates(scope=scope) + + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface + enrichRowsWithFkLabels(templates, AutoWorkflow, db=_getRootIface().db) + + if mode == "filterValues": + if not column: + raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") + from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory + return handleFilterValuesInMemory(templates, column, pagination) + + if mode == "ids": + from modules.dbHelpers.paginationHelpers import handleIdsInMemory + return handleIdsInMemory(templates, pagination) + + 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("/templates/from-workflow") +@limiter.limit("30/minute") +def _createTemplateFromWorkflow( + request: Request, + body: dict = Body(..., description="{ workflowId, scope? }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Create a template from an existing workflow.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + workflowId = body.get("workflowId") + scope = body.get("scope", "user") + if not workflowId: + raise HTTPException(status_code=400, detail=routeApiMsg("workflowId required")) + + db = _getWorkflowAutomationDb() + try: + wf = db.getRecord(AutoWorkflow, workflowId) if db._ensureTableExists(AutoWorkflow) else None + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "read") + mandateId = wf.get("mandateId", "") + instanceId = wf.get("featureInstanceId", "") + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, 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("/templates/{templateId}/copy") +@limiter.limit("30/minute") +def _copyTemplate( + request: Request, + templateId: str = Path(..., description="Template ID"), + body: dict = Body(default={}, description="{ mandateId? }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Copy a template to a new user-owned workflow.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + mandateId = body.get("mandateId") if isinstance(body, dict) else None + userId = str(context.user.id) + if not mandateId: + userMandateIds = _getUserMandateIds(userId) + mandateId = userMandateIds[0] if userMandateIds else "" + + db = _getWorkflowAutomationDb() + try: + instanceId = None + if db._ensureTableExists(AutoWorkflow) and mandateId: + wfs = db.getRecordset(AutoWorkflow, recordFilter={"mandateId": mandateId}) + for w in (wfs or []): + fid = w.get("featureInstanceId") + if fid: + instanceId = fid + break + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, mandateId or "", instanceId or "") + workflow = iface.copyTemplateToUser(templateId) + if not workflow: + raise HTTPException(status_code=404, detail=routeApiMsg("Template not found")) + return workflow + + +@router.post("/templates/{templateId}/share") +@limiter.limit("30/minute") +def _shareTemplate( + request: Request, + templateId: str = Path(..., description="Template ID"), + body: dict = Body(..., description="{ scope }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Share a template by changing its scope.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + 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")) + + mandateId = body.get("mandateId", "") + userId = str(context.user.id) + if not mandateId: + userMandateIds = _getUserMandateIds(userId) + mandateId = userMandateIds[0] if userMandateIds else "" + + db = _getWorkflowAutomationDb() + try: + instanceId = None + if db._ensureTableExists(AutoWorkflow) and mandateId: + wfs = db.getRecordset(AutoWorkflow, recordFilter={"mandateId": mandateId}) + for w in (wfs or []): + fid = w.get("featureInstanceId") + if fid: + instanceId = fid + break + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, mandateId or "", instanceId or "") + template = iface.shareTemplate(templateId, scope=scope) + if not template: + raise HTTPException(status_code=404, detail=routeApiMsg("Template not found")) + return template + + +# --------------------------------------------------------------------------- +# Group 5 — Connections (SharePoint etc.) +# --------------------------------------------------------------------------- + +def _buildServiceCenterContext(context: RequestContext, mandateId: str, instanceId: str = ""): + """Build a ServiceCenterContext for connector/service calls.""" + from modules.serviceCenter.context import ServiceCenterContext + return ServiceCenterContext( + user=context.user, + mandate_id=str(context.mandateId) if context.mandateId else mandateId, + feature_instance_id=instanceId, + ) + + +@router.get("/connections") +@limiter.limit("300/minute") +def _listConnections( + request: Request, + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return the user's active connections (UserConnections) for Email/SharePoint node config.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + mandateId = userMandateIds[0] if userMandateIds else "" + + from modules.serviceCenter import getService + ctx = _buildServiceCenterContext(context, mandateId) + 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("/connections/{connectionId}/services") +@limiter.limit("120/minute") +async def _listConnectionServices( + request: Request, + connectionId: str = Path(..., description="Connection ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return the available services for a specific UserConnection.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + mandateId = userMandateIds[0] if userMandateIds else "" + + try: + from modules.connectors.connectorResolver import ConnectorResolver + from modules.serviceCenter import getService as getSvc + ctx = _buildServiceCenterContext(context, mandateId) + 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)", "kdrive": "kDrive", + "calendar": "Calendar", "contact": "Contacts", + } + _serviceIcons = { + "sharepoint": "sharepoint", "clickup": "folder", "outlook": "mail", + "teams": "chat", "onedrive": "cloud", "drive": "cloud", + "gmail": "mail", "files": "folder", "kdrive": "cloud", + "calendar": "calendar", "contact": "contact", + } + 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("/connections/{connectionId}/browse") +@limiter.limit("300/minute") +async def _browseConnectionService( + request: Request, + 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.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + mandateId = userMandateIds[0] if userMandateIds else "" + + try: + from modules.connectors.connectorResolver import ConnectorResolver + from modules.serviceCenter import getService as getSvc + ctx = _buildServiceCenterContext(context, mandateId) + 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) + + +# --------------------------------------------------------------------------- +# Group 6 — Import / Export +# --------------------------------------------------------------------------- + +@router.post("/workflows/import") +@limiter.limit("30/minute") +def _importWorkflow( + request: Request, + body: dict = Body( + ..., + description=( + "{ envelope: , existingWorkflowId?: str, " + "fileId?: str, mandateId?: str } — supply EITHER the envelope " + "inline OR a fileId of a previously uploaded workflow file (.workflow.json)" + ), + ), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Import a workflow from a versioned-envelope file. + + Two input modes: + - ``envelope``: the parsed workflow-file payload + - ``fileId``: the id of a previously uploaded ``.workflow.json`` + """ + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + from modules.workflowAutomation.editor._workflowFileSchema import WorkflowFileSchemaError + + mandateId = body.get("mandateId") if isinstance(body, dict) else None + userId = str(context.user.id) + if not mandateId: + userMandateIds = _getUserMandateIds(userId) + mandateId = userMandateIds[0] if userMandateIds else "" + + if not mandateId and not context.isPlatformAdmin: + raise HTTPException(status_code=400, detail=routeApiMsg("mandateId required")) + + _validateWorkflowAccess(context, {"mandateId": mandateId}, "write") + + db = _getWorkflowAutomationDb() + try: + instanceId = None + if db._ensureTableExists(AutoWorkflow) and mandateId: + wfs = db.getRecordset(AutoWorkflow, recordFilter={"mandateId": mandateId}) + for w in (wfs or []): + fid = w.get("featureInstanceId") + if fid: + instanceId = fid + break + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, mandateId or "", instanceId or "") + + envelope = body.get("envelope") if isinstance(body, dict) else None + fileId = body.get("fileId") if isinstance(body, dict) else None + existingWorkflowId = body.get("existingWorkflowId") if isinstance(body, dict) else None + + if not envelope and fileId: + envelope = _loadEnvelopeFromFile(str(fileId), context) + + if not envelope: + raise HTTPException( + status_code=400, + detail=routeApiMsg("Body must contain 'envelope' or 'fileId'"), + ) + + try: + result = iface.importWorkflowFromDict(envelope, existingWorkflowId=existingWorkflowId) + except WorkflowFileSchemaError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + return result + + +@router.get("/workflows/{workflowId}/export") +@limiter.limit("60/minute") +def _exportWorkflow( + request: Request, + workflowId: str = Path(..., description="Workflow ID"), + download: bool = Query(False, description="If true, return as file download"), + context: RequestContext = Depends(getRequestContext), +): + """Export a workflow as a versioned-envelope JSON file.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + from modules.workflowAutomation.editor._workflowFileSchema import buildFileName + + db = _getWorkflowAutomationDb() + try: + wf = db.getRecord(AutoWorkflow, workflowId) if db._ensureTableExists(AutoWorkflow) else None + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "read") + mandateId = wf.get("mandateId", "") + instanceId = wf.get("featureInstanceId", "") + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, mandateId, instanceId) + envelope = iface.exportWorkflowToDict(workflowId) + if envelope is None: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + + if not download: + return {"envelope": envelope, "fileName": buildFileName(envelope.get("label", "workflow"))} + + fileName = buildFileName(envelope.get("label", "workflow")) + payload = json.dumps(envelope, ensure_ascii=False, indent=2).encode("utf-8") + return Response( + content=payload, + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{fileName}"'}, + ) + + +# --------------------------------------------------------------------------- +# Group 7 — Options +# --------------------------------------------------------------------------- + +@router.get("/options/user.connection") +@limiter.limit("60/minute") +def _getUserConnectionOptions( + request: Request, + authority: Optional[str] = Query(None, description="Optional authority filter (e.g. 'msft', 'google', 'clickup', 'local')"), + activeOnly: bool = Query(True, description="If true (default), only ACTIVE connections are returned"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return current user's UserConnections as { options: [{ value, label }] }. + + Used by node parameters with frontendType='userConnection'. + """ + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + rootInterface = getRootInterface() + try: + connections = rootInterface.getUserConnections(str(context.user.id)) or [] + except Exception as e: + logger.error("_getUserConnectionOptions: failed to load connections: %s", e, exc_info=True) + return {"options": []} + + wanted = (authority or "").strip().lower() or None + options: List[Dict[str, str]] = [] + for conn in connections: + connStatus = getattr(conn, "status", None) + statusVal = connStatus.value if hasattr(connStatus, "value") else str(connStatus or "") + if activeOnly and statusVal.lower() != "active": + continue + connAuthority = getattr(conn, "authority", None) + authorityVal = (connAuthority.value if hasattr(connAuthority, "value") else str(connAuthority or "")).lower() + if wanted and authorityVal != wanted: + continue + username = getattr(conn, "externalUsername", "") or "" + email = getattr(conn, "externalEmail", "") or "" + connId = str(getattr(conn, "id", "") or "") + labelParts = [p for p in [username, email] if p] + label = " — ".join(labelParts) if labelParts else connId + if authorityVal: + label = f"[{authorityVal}] {label}" + value = f"connection:{authorityVal}:{username}" if authorityVal and username else connId + options.append({"value": value, "label": label}) + + return {"options": options} + + +@router.get("/options/feature.instance") +@limiter.limit("60/minute") +def _getFeatureInstanceOptions( + request: Request, + featureCode: str = Query(..., description="Feature code to filter by (e.g. 'trustee', 'redmine', 'clickup')"), + enabledOnly: bool = Query(True, description="If true (default), only enabled feature instances are returned"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return mandate-scoped FeatureInstances for the given featureCode. + + Used by node parameters with frontendType='featureInstance'. + """ + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + code = (featureCode or "").strip().lower() + if not code: + raise HTTPException(status_code=400, detail=routeApiMsg("featureCode query parameter is required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + if not userMandateIds and not context.isPlatformAdmin: + return {"options": []} + + rootInterface = getRootInterface() + allOptions: List[Dict[str, str]] = [] + + targetMandateIds = userMandateIds if not context.isPlatformAdmin else [] + if context.isPlatformAdmin: + try: + from modules.datamodels.datamodelMandate import Mandate + mandates = rootInterface.db.getRecordset(Mandate) or [] + targetMandateIds = [str(m.get("id") if isinstance(m, dict) else getattr(m, "id", "")) for m in mandates] + except Exception: + targetMandateIds = [] + + for mid in targetMandateIds: + try: + instances = rootInterface.getFeatureInstancesByMandate(mid, enabledOnly=bool(enabledOnly)) or [] + except Exception as e: + logger.error("_getFeatureInstanceOptions: failed to load instances mandateId=%s: %s", mid, e, exc_info=True) + continue + + for fi in instances: + fiCode = (getattr(fi, "featureCode", "") or "").strip().lower() + if fiCode != code: + continue + fiId = str(getattr(fi, "id", "") or "") + if not fiId: + continue + rawLabel = getattr(fi, "label", None) or getattr(fi, "name", None) or fiId + allOptions.append({"value": fiId, "label": f"{rawLabel} ({fiCode})"}) + + return {"options": allOptions} + + +# --------------------------------------------------------------------------- +# Group 8 — Metrics +# --------------------------------------------------------------------------- + +@router.get("/metrics") +@limiter.limit("60/minute") +def _getMetrics( + request: Request, + mandateId: Optional[str] = Query(None, description="Filter metrics by mandate"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Aggregated metrics for the monitoring dashboard.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + + if mandateId: + if not context.isPlatformAdmin and mandateId not in userMandateIds: + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + scopeFilter: Dict[str, Any] = {"mandateId": mandateId, "isTemplate": False} + elif context.isPlatformAdmin: + scopeFilter = {"isTemplate": False} + elif userMandateIds: + scopeFilter = {"mandateId": userMandateIds, "isTemplate": False} + else: + return { + "workflowCount": 0, "activeWorkflows": 0, "totalRuns": 0, + "runsByStatus": {}, "totalTasks": 0, "tasksByStatus": {}, + "totalTokens": 0, "totalCredits": 0.0, + } + + db = _getWorkflowAutomationDb() + try: + workflows = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter) or [] if db._ensureTableExists(AutoWorkflow) else [] + wfIds = [w.get("id") for w in workflows] + runFilter = {"workflowId": {"$in": wfIds}} if wfIds else {"workflowId": "__none__"} + runs = db.getRecordset(AutoRun, recordFilter=runFilter) or [] if db._ensureTableExists(AutoRun) else [] + tasks = db.getRecordset(AutoTask, recordFilter=runFilter) or [] if db._ensureTableExists(AutoTask) else [] + finally: + db.close() + + runsByStatus: Dict[str, int] = {} + 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: Dict[str, int] = {} + 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), + } + + +# --------------------------------------------------------------------------- +# Group 9 — SSE Stream + Stop + Run Detail +# --------------------------------------------------------------------------- + +@router.get("/runs/{runId}/stream") +async def _getRunStream( + request: Request, + runId: str = Path(..., description="Run ID"), + context: RequestContext = Depends(getRequestContext), +): + """SSE stream for live step-log updates during a workflow run.""" + db = _getWorkflowAutomationDb() + try: + if not db._ensureTableExists(AutoRun): + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + + runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) + if not runs: + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + run = dict(runs[0]) + finally: + db.close() + + if not context.isPlatformAdmin: + userId = str(context.user.id) if context.user else None + runOwner = run.get("ownerId") + runMandate = run.get("mandateId") + if runOwner == userId: + pass + elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): + pass + else: + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + + 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", + }, + ) + + +@router.post("/runs/{runId}/stop") +@limiter.limit("30/minute") +def _stopWorkflowRun( + request: Request, + runId: str = Path(..., description="Run ID"), + context: RequestContext = Depends(getRequestContext), +): + """Stop a running workflow execution.""" + db = _getWorkflowAutomationDb() + try: + if not db._ensureTableExists(AutoRun): + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + + runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) + if not runs: + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + run = dict(runs[0]) + + if not context.isPlatformAdmin: + userId = str(context.user.id) if context.user else None + runOwner = run.get("ownerId") + runMandate = run.get("mandateId") + if runOwner == userId: + pass + elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): + pass + else: + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + + from modules.workflowAutomation.engine.executionEngine import requestRunStop + flagged = requestRunStop(runId) + + if not flagged: + currentStatus = run.get("status", "") + if currentStatus in ("completed", "failed", "stopped"): + return {"status": currentStatus, "runId": runId, "message": "Run already finished"} + stopUpdates: Dict[str, Any] = {"status": "stopped"} + if not run.get("completedAt"): + stopUpdates["completedAt"] = time.time() + db.recordModify(AutoRun, runId, stopUpdates) + return {"status": "stopped", "runId": runId, "message": "Run not active in memory, marked as stopped"} + + return {"status": "stopping", "runId": runId, "message": "Stop signal sent"} + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Run Detail (enriched with step logs, workflow info, files) +# --------------------------------------------------------------------------- + +_FILE_REF_KEYS = ("fileId", "documentId", "fileIds", "documents") + + +def _extractFileIdsFromValue(value, accumulator: set) -> None: + """Recursively scan a value (dict/list/str) for file id references.""" + if isinstance(value, dict): + for key, sub in value.items(): + if key in _FILE_REF_KEYS: + _collectFileIdsFromRef(sub, accumulator) + else: + _extractFileIdsFromValue(sub, accumulator) + elif isinstance(value, list): + for item in value: + _extractFileIdsFromValue(item, accumulator) + + +def _collectFileIdsFromRef(val, accumulator: set) -> None: + """Add file ids from a value located under a known file-reference key.""" + if isinstance(val, str) and val: + accumulator.add(val) + elif isinstance(val, list): + for v in val: + if isinstance(v, str) and v: + accumulator.add(v) + elif isinstance(v, dict) and v.get("id"): + accumulator.add(v["id"]) + elif isinstance(val, dict) and val.get("id"): + accumulator.add(val["id"]) + + +@router.get("/runs/{runId}/detail") +@limiter.limit("60/minute") +def _getRunDetail( + request: Request, + runId: str = Path(..., description="Run ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Get full detail for a single run: metadata, step logs, linked files.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + db = _getWorkflowAutomationDb() + + try: + if not db._ensureTableExists(AutoRun): + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + + runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) + if not runs: + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + run = dict(runs[0]) + + wfId = run.get("workflowId") + workflow: dict = {} + if wfId and db._ensureTableExists(AutoWorkflow): + wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfId}) + if wfs: + workflow = dict(wfs[0]) + + tid = workflow.get("targetFeatureInstanceId") or workflow.get("featureInstanceId") + accessibleIds = _getUserAccessibleInstanceIds(userId) + isOwner = run.get("ownerId") == userId + + if not isOwner and (not tid or tid not in accessibleIds) and not context.isPlatformAdmin: + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + + steps: list = [] + if db._ensureTableExists(AutoStepLog): + stepRecords = db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or [] + steps = [dict(s) for s in stepRecords] + steps.sort(key=lambda s: s.get("startedAt") or 0) + + allFileIds: set = set() + perStepFileIds: list = [] + for step in steps: + inputIds: set = set() + outputIds: set = set() + _extractFileIdsFromValue(step.get("inputSnapshot") or {}, inputIds) + _extractFileIdsFromValue(step.get("output") or {}, outputIds) + perStepFileIds.append((inputIds, outputIds)) + allFileIds.update(inputIds) + allFileIds.update(outputIds) + + nodeOutputs = run.get("nodeOutputs") or {} + runLevelIds: set = set() + _extractFileIdsFromValue(nodeOutputs, runLevelIds) + allFileIds.update(runLevelIds) + + fileMetaById: dict = {} + try: + from modules.datamodels.datamodelFiles import FileItem + from modules.interfaces.interfaceDbManagement import ComponentObjects + mgmtDb = ComponentObjects().db + if mgmtDb._ensureTableExists(FileItem): + for fid in allFileIds: + try: + rec = mgmtDb.getRecord(FileItem, fid) + if rec: + recDict = dict(rec) + fileMetaById[fid] = { + "id": fid, + "fileName": recDict.get("fileName") or recDict.get("name"), + } + except Exception: + pass + except Exception as e: + logger.warning("_getRunDetail: file lookup failed: %s", e) + + from modules.workflowAutomation.engine.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui + + def _resolveFileList(ids: set) -> list: + rows = [dict(fileMetaById[fid]) for fid in ids if fid in fileMetaById] + return [m for m in rows if not suppress_workflow_file_in_workspace_ui(m)] + + assignedFileIds: set = set() + for step, (inputIds, outputIds) in zip(steps, perStepFileIds): + step["inputFiles"] = _resolveFileList(inputIds) + step["outputFiles"] = _resolveFileList(outputIds) + assignedFileIds.update(inputIds) + assignedFileIds.update(outputIds) + + unassignedFiles = _resolveFileList(allFileIds - assignedFileIds) + allFiles = _resolveFileList(allFileIds) + + run["workflowLabel"] = run.get("label") or workflow.get("label") or wfId + run["targetFeatureInstanceId"] = tid + + targetInstanceLabel = None + if tid: + try: + from modules.dbHelpers.fkLabelResolver import resolveInstanceLabels + labelMap = resolveInstanceLabels(db, [tid]) + targetInstanceLabel = labelMap.get(tid) + except Exception: + pass + run["targetInstanceLabel"] = targetInstanceLabel + + return { + "run": run, + "workflow": { + "id": workflow.get("id"), + "label": workflow.get("label"), + "targetFeatureInstanceId": tid, + "featureInstanceId": workflow.get("featureInstanceId"), + "tags": workflow.get("tags", []), + } if workflow else None, + "steps": steps, + "files": allFiles, + "unassignedFiles": unassignedFiles, + } + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Execute workflow +# --------------------------------------------------------------------------- + +def _buildExecuteRunEnvelope( + body: Dict[str, Any], + workflow: Optional[Dict[str, Any]], + userId: Optional[str], + requestLang: Optional[str] = None, +) -> Dict[str, Any]: + """Build normalized run envelope from POST /execute body.""" + from modules.workflowAutomation.engine.runEnvelope import ( + default_run_envelope, + merge_run_envelope, + normalize_run_envelope, + ) + from modules.workflowAutomation.editor.entryPoints import find_invocation + + if isinstance(body.get("runEnvelope"), dict): + env = normalize_run_envelope(body["runEnvelope"], user_id=userId) + pl = body.get("payload") + if isinstance(pl, dict): + env = merge_run_envelope(env, {"payload": pl}) + return env + + entryPointId = body.get("entryPointId") + if entryPointId: + 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, entryPointId) + 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") + trigMap = { + "manual": "manual", + "form": "form", + "schedule": "schedule", + "always_on": "event", + "email": "email", + "webhook": "webhook", + "api": "api", + "event": "event", + } + trig = trigMap.get(kind, "manual") + title = inv.get("title") or {} + label = resolveText(title) + 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=userId) + + env = normalize_run_envelope(None, user_id=userId) + pl = body.get("payload") + if isinstance(pl, dict): + env = merge_run_envelope(env, {"payload": pl}) + return env + + +@router.post("/workflows/{workflowId}/execute") +@limiter.limit("30/minute") +async def _executeWorkflow( + request: Request, + workflowId: str = Path(..., description="Workflow ID"), + body: dict = Body(..., description="{ graph?, entryPointId?, payload?, runEnvelope? }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Execute a workflow graph.""" + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.engine.executionEngine import executeGraph + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + from modules.workflows.processing.shared.methodDiscovery import discoverMethods + + userId = str(context.user.id) if context.user else None + logger.info("workflowAutomation execute: workflowId=%s userId=%s", workflowId, userId) + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "execute") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + targetFeatureInstanceId = wf.get("targetFeatureInstanceId") + + services = _getWorkflowAutomationServices( + context.user, + mandateId=mandateId, + featureInstanceId=instanceId, + ) + discoverMethods(services) + + graph = body.get("graph") or body + reqNodes = graph.get("nodes") or [] + workflowForEnvelope: Optional[Dict[str, Any]] = wf + + if len(reqNodes) == 0: + graph = wf.get("graph") or {} + logger.info("workflowAutomation execute: loaded graph from workflow %s", workflowId) + + nodesCount = len(graph.get("nodes") or []) + connectionsCount = len(graph.get("connections") or []) + logger.info( + "workflowAutomation execute: graph nodes=%d connections=%d workflowId=%s mandateId=%s", + nodesCount, connectionsCount, workflowId, mandateId, + ) + + runEnv = _buildExecuteRunEnvelope( + body, + workflowForEnvelope, + userId, + getattr(context.user, "language", None) if context.user else None, + ) + + wfLabel = wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", None) + + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + result = await executeGraph( + graph=graph, + services=services, + workflowId=workflowId, + instanceId=instanceId, + userId=userId, + mandateId=mandateId, + automation2_interface=iface, + run_envelope=runEnv, + label=wfLabel, + targetFeatureInstanceId=targetFeatureInstanceId, + ) + logger.info( + "workflowAutomation execute result: success=%s error=%s paused=%s", + result.get("success"), result.get("error"), result.get("paused"), + ) + return result + + +# --------------------------------------------------------------------------- +# Version management +# --------------------------------------------------------------------------- + +@router.post("/workflows/{workflowId}/versions/draft") +@limiter.limit("30/minute") +async def _createDraftVersion( + request: Request, + workflowId: str = Path(..., description="Workflow ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Create a new draft version from the workflow's current graph.""" + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "write") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + iface = _getWorkflowAutomationInterface(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("/versions/{versionId}/publish") +@limiter.limit("30/minute") +async def _publishVersion( + request: Request, + versionId: str = Path(..., description="Version ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Publish a draft version. Archives the previously published version.""" + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoVersion) + ver = db.getRecord(AutoVersion, versionId) + if not ver: + raise HTTPException(status_code=404, detail=routeApiMsg("Version not found")) + wfId = ver.get("workflowId") + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, wfId) if wfId else None + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "write") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + userId = str(context.user.id) if context.user else None + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + 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("/versions/{versionId}/unpublish") +@limiter.limit("30/minute") +async def _unpublishVersion( + request: Request, + versionId: str = Path(..., description="Version ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Unpublish a version (revert to draft).""" + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoVersion) + ver = db.getRecord(AutoVersion, versionId) + if not ver: + raise HTTPException(status_code=404, detail=routeApiMsg("Version not found")) + wfId = ver.get("workflowId") + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, wfId) if wfId else None + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "write") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + iface = _getWorkflowAutomationInterface(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("/versions/{versionId}/archive") +@limiter.limit("30/minute") +async def _archiveVersion( + request: Request, + versionId: str = Path(..., description="Version ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Archive a version.""" + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoVersion) + ver = db.getRecord(AutoVersion, versionId) + if not ver: + raise HTTPException(status_code=404, detail=routeApiMsg("Version not found")) + wfId = ver.get("workflowId") + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, wfId) if wfId else None + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "write") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + version = iface.archiveVersion(versionId) + if not version: + raise HTTPException(status_code=404, detail=routeApiMsg("Version not found")) + return version + + +# --------------------------------------------------------------------------- +# Node types + Editor metadata +# --------------------------------------------------------------------------- + +@router.get("/node-types") +@limiter.limit("60/minute") +async def _getNodeTypes( + request: Request, + mandateId: str = Query(..., description="Mandate ID for context"), + featureInstanceId: Optional[str] = Query(default=None, 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.""" + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.editor.nodeRegistry import getNodeTypesForApi + + logger.info("workflowAutomation node-types: mandateId=%s language=%s", mandateId, language) + services = _getWorkflowAutomationServices( + context.user, + mandateId=mandateId, + featureInstanceId=featureInstanceId or "", + ) + result = getNodeTypesForApi(services, language=language) + logger.info( + "workflowAutomation node-types response: %d nodeTypes %d categories", + len(result.get("nodeTypes", [])), + len(result.get("categories", [])), + ) + return result + + +@router.post("/upstream-paths") +@limiter.limit("60/minute") +async def _postUpstreamPaths( + request: Request, + body: Dict[str, Any] = Body(...), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return pickable upstream DataRef paths for a node (draft graph in body).""" + from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths + + graph = body.get("graph") + nodeId = body.get("nodeId") + if not isinstance(graph, dict) or not nodeId: + raise HTTPException(status_code=400, detail=routeApiMsg("graph and nodeId are required")) + paths = compute_upstream_paths(graph, str(nodeId)) + return {"paths": paths} + + +@router.post("/condition-meta") +@limiter.limit("120/minute") +async def _postConditionMeta( + request: Request, + body: Dict[str, Any] = Body(...), + language: str = Query("de", description="Localization (en, de, fr)"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return valueKind and operators for a DataRef (backend-driven If/Else UI).""" + from modules.workflowAutomation.editor.conditionOperators import resolve_condition_meta + + graph = body.get("graph") + ref = body.get("ref") + nodeId = body.get("nodeId") + if not isinstance(graph, dict) or not isinstance(ref, dict): + raise HTTPException(status_code=400, detail=routeApiMsg("graph and ref are required")) + graphPayload = dict(graph) + if nodeId: + graphPayload["targetNodeId"] = str(nodeId) + return resolve_condition_meta(graphPayload, ref, lang=language) + + +@router.post("/graph-data-sources") +@limiter.limit("120/minute") +async def _postGraphDataSources( + request: Request, + body: Dict[str, Any] = Body(...), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Scope-aware data sources for the DataPicker.""" + from modules.workflowAutomation.editor.upstreamPathsService import compute_graph_data_sources + + graph = body.get("graph") + nodeId = body.get("nodeId") + if not isinstance(graph, dict) or not nodeId: + raise HTTPException(status_code=400, detail=routeApiMsg("graph and nodeId are required")) + return compute_graph_data_sources(graph, str(nodeId)) + + +@router.get("/upstream-paths/{nodeId}") +@limiter.limit("60/minute") +async def _getUpstreamPathsSaved( + request: Request, + nodeId: str = Path(..., description="Target node id"), + workflowId: str = Query(..., description="Workflow id whose saved graph is used"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return upstream paths using the persisted workflow graph.""" + from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths + + if not workflowId: + raise HTTPException(status_code=400, detail=routeApiMsg("workflowId is required")) + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "read") + + graph = wf.get("graph") or {} + paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(nodeId)) + return {"paths": paths} + + +# --------------------------------------------------------------------------- +# Tasks complete/cancel +# --------------------------------------------------------------------------- + +@router.post("/tasks/{taskId}/complete") +@limiter.limit("30/minute") +async def _completeTask( + request: Request, + taskId: str = Path(..., description="Task ID"), + body: dict = Body(..., description="{ result }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Complete a human task and resume the workflow.""" + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.engine.executionEngine import executeGraph + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoTask) + task = db.getRecord(AutoTask, taskId) + finally: + db.close() + + if not task: + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) + + wfId = task.get("workflowId") + db2 = _getWorkflowAutomationDb() + try: + db2._ensureTableExists(AutoWorkflow) + wf = db2.getRecord(AutoWorkflow, wfId) if wfId else None + finally: + db2.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "execute") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + + taskRecord = iface.getTask(taskId) + if not taskRecord: + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) + + runId = taskRecord.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 taskRecord.get("status") != "pending": + raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed")) + + iface.updateTask(taskId, status="completed", result=result) + taskNodeId = taskRecord.get("nodeId") + nodeOutputs = dict(run.get("nodeOutputs") or {}) + nodeOutputs[taskNodeId] = result + + workflowId = run.get("workflowId") + wfForGraph = iface.getWorkflow(workflowId) if workflowId else None + if not wfForGraph or not wfForGraph.get("graph"): + raise HTTPException(status_code=400, detail=routeApiMsg("Workflow graph not found")) + + graph = wfForGraph["graph"] + services = _getWorkflowAutomationServices(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=taskNodeId, + runId=runId, + ) + + +@router.post("/tasks/{taskId}/cancel") +@limiter.limit("30/minute") +async def _cancelTask( + request: Request, + taskId: str = Path(..., description="Human task ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Cancel a pending human task and stop the workflow run behind it.""" + from modules.workflowAutomation.engine.executionEngine import requestRunStop + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoTask) + task = db.getRecord(AutoTask, taskId) + finally: + db.close() + + if not task: + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) + + wfId = task.get("workflowId") + db2 = _getWorkflowAutomationDb() + try: + db2._ensureTableExists(AutoWorkflow) + wf = db2.getRecord(AutoWorkflow, wfId) if wfId else None + finally: + db2.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) + _validateWorkflowAccess(context, wf, "execute") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + + taskRecord = iface.getTask(taskId) + if not taskRecord: + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) + if taskRecord.get("status") != "pending": + raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed")) + + runId = taskRecord.get("runId") + + if runId: + requestRunStop(runId) + dbRun = iface.getRun(runId) + if dbRun: + current = dbRun.get("status") or "" + if current not in ("completed", "failed", "cancelled"): + iface.updateRun(runId, status="cancelled") + + pending = iface.getTasks(runId=runId, status="pending") + for t in pending: + tid = t.get("id") + if tid: + iface.updateTask(tid, status="cancelled") + else: + iface.updateTask(taskId, status="cancelled") + + return {"success": True, "runId": runId, "taskId": taskId} diff --git a/modules/routes/routeWorkflowDashboard.py b/modules/routes/routeWorkflowDashboard.py deleted file mode 100644 index 020e5ec7..00000000 --- a/modules/routes/routeWorkflowDashboard.py +++ /dev/null @@ -1,1293 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -System-level Workflow Dashboard API. - -Provides cross-feature, cross-mandate access to workflow runs AND workflows -with RBAC scoping: user sees own runs/workflows, mandate admin sees mandate -runs/workflows, sysadmin sees all. -""" - -import asyncio -import json -import logging -import math -import re -import time -from datetime import datetime, timezone -from functools import partial -from typing import Optional, List -from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException -from fastapi.responses import StreamingResponse -from slowapi import Limiter -from slowapi.util import get_remote_address - -from modules.auth.authentication import getRequestContext, RequestContext -from modules.interfaces.interfaceDbApp import getRootInterface -from modules.connectors.connectorDbPostgre import DatabaseConnector -from modules.shared.configuration import APP_CONFIG -from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict -from modules.datamodels.datamodelWorkflowAutomation import ( - AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion, - GRAPHICAL_EDITOR_DATABASE, -) -from modules.shared.i18nRegistry import apiRouteContext - -routeApiMsg = apiRouteContext("routeWorkflowDashboard") - -logger = logging.getLogger(__name__) -limiter = Limiter(key_func=get_remote_address) - -router = APIRouter(prefix="/api/system/workflow-runs", tags=["WorkflowDashboard"]) - - -def _getDb() -> DatabaseConnector: - return DatabaseConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=GRAPHICAL_EDITOR_DATABASE, - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId=None, - ) - - -def _getUserMandateIds(userId: str) -> list[str]: - """Get mandate IDs the user is a member of.""" - rootIface = getRootInterface() - memberships = rootIface.getUserMandates(userId) - return [um.mandateId for um in memberships if um.mandateId and um.enabled] - - -def _getAdminMandateIds(userId: str, mandateIds: list) -> list: - """Batch-check which mandates the user is admin for (UserMandate → UserMandateRole → Role).""" - if not mandateIds: - return [] - rootIface = getRootInterface() - from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole - - memberships = rootIface.db.getRecordset( - UserMandate, - recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True}, - ) - if not memberships: - return [] - - umIdToMandateId: dict[str, str] = {} - for m in memberships: - row = m if isinstance(m, dict) else m.__dict__ - um_id = row.get("id") - mid = row.get("mandateId") - if um_id and mid: - umIdToMandateId[str(um_id)] = str(mid) - - userMandateIds = list(umIdToMandateId.keys()) - allRoles = rootIface.db.getRecordset( - UserMandateRole, - recordFilter={"userMandateId": userMandateIds}, - ) - if not allRoles: - return [] - - roleIds = set() - roleToMandate: dict = {} - for r in allRoles: - row = r if isinstance(r, dict) else r.__dict__ - rid = row.get("roleId") - um_id = row.get("userMandateId") - mid = umIdToMandateId.get(str(um_id)) if um_id else None - if rid and mid: - roleIds.add(rid) - roleToMandate.setdefault(rid, set()).add(mid) - - if not roleIds: - return [] - - from modules.datamodels.datamodelRbac import Role - roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)}) - adminMandates: set = set() - for role in (roleRecords or []): - row = role if isinstance(role, dict) else role.__dict__ - rid = row.get("id") - if not rid or rid not in roleToMandate: - continue - # Same rule as routeBilling._isAdminOfMandate / notifyMandateAdmins - if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"): - adminMandates.update(roleToMandate[rid]) - - return [mid for mid in mandateIds if mid in adminMandates] - - -def _isUserMandateAdmin(userId: str, mandateId: str) -> bool: - """Check if user is admin for a specific mandate.""" - adminIds = _getAdminMandateIds(userId, [mandateId]) - return mandateId in adminIds - - -def _scopedRunFilter(context: RequestContext) -> Optional[dict]: - """ - Build a DB filter dict based on RBAC: - - sysadmin: None (no filter) - - mandate admin: mandateId IN user's mandates - - normal user: ownerId = userId - """ - if context.isPlatformAdmin: - return None - - userId = str(context.user.id) if context.user else None - if not userId: - return {"ownerId": "__impossible__"} - - mandateIds = _getUserMandateIds(userId) - adminMandateIds = _getAdminMandateIds(userId, mandateIds) - - if adminMandateIds: - return {"mandateId": adminMandateIds} - - return {"ownerId": userId} - - -def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]: - """ - Build a DB filter for AutoWorkflow based on RBAC: - - sysadmin: None (no filter, sees all) - - normal user: mandateId IN user's mandates - """ - if context.isPlatformAdmin: - return None - - userId = str(context.user.id) if context.user else None - if not userId: - return {"mandateId": "__impossible__"} - - mandateIds = _getUserMandateIds(userId) - if mandateIds: - return {"mandateId": mandateIds} - - return {"mandateId": "__impossible__"} - - -def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool: - """Same rules as canDelete on rows in get_system_workflows.""" - if context.isPlatformAdmin: - return True - userId = str(context.user.id) if context.user else None - if not userId or not wfMandateId: - return False - userMandateIds = _getUserMandateIds(userId) - adminMandateIds = _getAdminMandateIds(userId, userMandateIds) - return wfMandateId in adminMandateIds - - -def _parsePaginationOr400(pagination: Optional[str]) -> Optional[PaginationParams]: - """Parse a JSON pagination query string into PaginationParams. - - Returns None when the input is empty/None. Raises HTTPException(400) on any - parse / validation error so the caller can propagate the error to the - client instead of silently falling back to defaults (which used to mask - real frontend bugs). - """ - if not pagination: - return None - try: - paginationDict = json.loads(pagination) - except json.JSONDecodeError as e: - raise HTTPException( - status_code=400, - detail=f"Invalid 'pagination' query: not valid JSON ({e.msg})", - ) - if not paginationDict: - return None - try: - paginationDict = normalize_pagination_dict(paginationDict) - return PaginationParams(**paginationDict) - except Exception as e: - raise HTTPException( - status_code=400, - detail=f"Invalid 'pagination' payload: {e}", - ) - - -_RUN_STATS_SUBQUERY = """ -( - SELECT s."workflowId" AS "workflowId", - MAX(COALESCE(s."startedAt", s."sysCreatedAt")) AS "lastStartedAt", - COUNT(s."id")::bigint AS "runCount", - MAX(CASE WHEN s."status" IN ('running', 'paused') THEN s."id" END) AS "activeRunId" - FROM "AutoRun" s - GROUP BY s."workflowId" -) rs -""" - - -def _firstFkSortFieldForWorkflows(pagination) -> Optional[str]: - """First sort field that requires FK label resolution (cross-DB), or None.""" - from modules.dbHelpers.fkLabelResolver import buildLabelResolversFromModel - if not pagination or not pagination.sort: - return None - resolvers = buildLabelResolversFromModel(AutoWorkflow) - if not resolvers: - return None - for sf in pagination.sort: - sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) - if sfField and sfField in resolvers: - return sfField - return None - - -def _batchRunStatsForWorkflowIds(db: DatabaseConnector, workflowIds: List[str]) -> dict: - """One grouped query: lastStartedAt, runCount, activeRunId per workflow.""" - if not workflowIds or not db._ensureTableExists(AutoRun): - return {} - db._ensure_connection() - sql = """ -SELECT "workflowId", - MAX(COALESCE("startedAt", "sysCreatedAt")) AS "lastStartedAt", - COUNT("id")::bigint AS "runCount", - MAX(CASE WHEN "status" IN ('running', 'paused') THEN "id" END) AS "activeRunId" -FROM "AutoRun" -WHERE "workflowId" = ANY(%s) -GROUP BY "workflowId" -""" - out: dict = {} - with db.borrowCursor() as cursor: - cursor.execute(sql, (workflowIds,)) - for row in cursor.fetchall(): - r = dict(row) - wid = r.get("workflowId") - if wid: - out[str(wid)] = r - return out - - -def _listingColSql(key: str, wfFieldNames: set) -> Optional[str]: - if key == "lastStartedAt": - return 'rs."lastStartedAt"' - if key == "runCount": - return 'COALESCE(rs."runCount", 0::bigint)' - if key == "isRunning": - return '(rs."activeRunId" IS NOT NULL)' - if key in wfFieldNames: - return f'w."{key}"' - return None - - -def _listingOrderExpr(key: str, wfFieldNames: set, wfFields: dict) -> Optional[str]: - if key == "lastStartedAt": - return 'rs."lastStartedAt"' - if key == "runCount": - return 'COALESCE(rs."runCount", 0::bigint)' - if key == "isRunning": - return 'CASE WHEN rs."activeRunId" IS NOT NULL THEN 1 ELSE 0 END' - if key in wfFieldNames: - colType = wfFields.get(key, "TEXT") - if colType == "BOOLEAN": - return f'COALESCE(w."{key}", FALSE)' - return f'w."{key}"' - return None - - -def _appendJoinedListingFilters(whereParts: list, values: list, pagination, wfFields: dict) -> None: - """Append WHERE fragments for joined workflow listing (w + rs).""" - wfFieldNames = set(wfFields.keys()) - validCols = wfFieldNames | {"lastStartedAt", "runCount", "isRunning"} - - if not pagination or not pagination.filters: - return - - for key, val in pagination.filters.items(): - if key == "search" and isinstance(val, str) and val.strip(): - term = f"%{val.strip()}%" - textCols = [c for c, t in wfFields.items() if t == "TEXT"] - if textCols: - orParts = [f'COALESCE(w."{c}"::TEXT, \'\') ILIKE %s' for c in textCols] - whereParts.append(f"({' OR '.join(orParts)})") - values.extend([term] * len(textCols)) - continue - - if key not in validCols: - continue - - if key == "isRunning": - if isinstance(val, dict): - op = val.get("operator", "equals") - v = val.get("value", "") - isTrue = str(v).lower() == "true" - if op in ("equals", "eq"): - whereParts.append('(rs."activeRunId" IS NOT NULL)' if isTrue else '(rs."activeRunId" IS NULL)') - elif val is None: - whereParts.append('(rs."activeRunId" IS NULL)') - else: - whereParts.append( - '(rs."activeRunId" IS NOT NULL)' if str(val).lower() == "true" else '(rs."activeRunId" IS NULL)' - ) - continue - - colRef = _listingColSql(key, wfFieldNames) - if not colRef: - continue - - colType = wfFields.get(key, "TEXT") if key in wfFieldNames else ( - "DOUBLE PRECISION" if key == "lastStartedAt" else "BIGINT" if key == "runCount" else "TEXT" - ) - - if val is None: - if key == "lastStartedAt": - whereParts.append(f'({colRef} IS NULL)') - elif key == "runCount": - whereParts.append(f'({colRef} = 0)') - else: - whereParts.append(f'({colRef} IS NULL OR {colRef}::TEXT = \'\')') - continue - - if not isinstance(val, dict): - if colType == "BOOLEAN" or key == "isRunning": - whereParts.append(f'COALESCE({colRef}, FALSE) = %s') - values.append(str(val).lower() == "true") - else: - whereParts.append(f'{colRef}::TEXT ILIKE %s') - values.append(str(val)) - continue - - op = val.get("operator", "equals") - v = val.get("value", "") - if op in ("equals", "eq"): - if colType == "BOOLEAN": - whereParts.append(f'COALESCE({colRef}, FALSE) = %s') - values.append(str(v).lower() == "true") - else: - whereParts.append(f'{colRef}::TEXT = %s') - values.append(str(v)) - elif op == "contains": - whereParts.append(f'{colRef}::TEXT ILIKE %s') - values.append(f"%{v}%") - elif op == "startsWith": - whereParts.append(f'{colRef}::TEXT ILIKE %s') - values.append(f"{v}%") - elif op == "endsWith": - whereParts.append(f'{colRef}::TEXT ILIKE %s') - values.append(f"%{v}") - elif op in ("gt", "gte", "lt", "lte"): - sqlOp = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op] - if colType in ("INTEGER", "DOUBLE PRECISION", "BIGINT") or key in ("lastStartedAt", "runCount"): - try: - whereParts.append(f'{colRef}::double precision {sqlOp} %s') - values.append(float(v)) - except (ValueError, TypeError): - continue - else: - whereParts.append(f'{colRef}::TEXT {sqlOp} %s') - values.append(str(v)) - elif op == "between": - fromVal = v.get("from", "") if isinstance(v, dict) else "" - toVal = v.get("to", "") if isinstance(v, dict) else "" - if not fromVal and not toVal: - continue - isNumericCol = colType in ("INTEGER", "DOUBLE PRECISION", "BIGINT") or key in ("lastStartedAt", "runCount") - isDateVal = bool(fromVal and re.match(r"^\d{4}-\d{2}-\d{2}$", str(fromVal))) or bool( - toVal and re.match(r"^\d{4}-\d{2}-\d{2}$", str(toVal)) - ) - if isNumericCol and isDateVal: - if fromVal and toVal: - fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() - toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( - hour=23, minute=59, second=59, tzinfo=timezone.utc - ).timestamp() - whereParts.append(f"({colRef} >= %s AND {colRef} <= %s)") - values.extend([fromTs, toTs]) - elif fromVal: - fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() - whereParts.append(f"({colRef} >= %s)") - values.append(fromTs) - else: - toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( - hour=23, minute=59, second=59, tzinfo=timezone.utc - ).timestamp() - whereParts.append(f"({colRef} <= %s)") - values.append(toTs) - elif isNumericCol: - try: - if fromVal and toVal: - whereParts.append( - f"({colRef}::double precision >= %s AND {colRef}::double precision <= %s)" - ) - values.extend([float(fromVal), float(toVal)]) - elif fromVal: - whereParts.append(f"{colRef}::double precision >= %s") - values.append(float(fromVal)) - elif toVal: - whereParts.append(f"{colRef}::double precision <= %s") - values.append(float(toVal)) - except (ValueError, TypeError): - continue - else: - if fromVal and toVal: - whereParts.append(f"({colRef}::TEXT >= %s AND {colRef}::TEXT <= %s)") - values.extend([str(fromVal), str(toVal)]) - elif fromVal: - whereParts.append(f"{colRef}::TEXT >= %s") - values.append(str(fromVal)) - elif toVal: - whereParts.append(f"{colRef}::TEXT <= %s") - values.append(str(toVal)) - - -def _buildJoinedWorkflowWhereOrderLimit( - recordFilter: dict, - pagination, - wfFields: dict, -) -> tuple: - """WHERE / ORDER BY / LIMIT for joined AutoWorkflow + run stats listing.""" - wfFieldNames = set(wfFields.keys()) - whereParts: list = [] - values: list = [] - - for field, value in (recordFilter or {}).items(): - if value is None: - whereParts.append(f'w."{field}" IS NULL') - elif isinstance(value, list): - whereParts.append(f'w."{field}" = ANY(%s)') - values.append(value) - else: - whereParts.append(f'w."{field}" = %s') - values.append(value) - - _appendJoinedListingFilters(whereParts, values, pagination, wfFields) - - whereClause = " WHERE " + " AND ".join(whereParts) if whereParts else "" - - orderParts: list = [] - if pagination and pagination.sort: - for sf in pagination.sort: - sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) - sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc") - if not sfField: - continue - expr = _listingOrderExpr(sfField, wfFieldNames, wfFields) - if not expr: - continue - direction = "DESC" if str(sfDir).lower() == "desc" else "ASC" - orderParts.append(f"{expr} {direction} NULLS LAST") - if not orderParts: - orderParts.append('w."sysCreatedAt" DESC NULLS LAST') - - orderClause = " ORDER BY " + ", ".join(orderParts) - - limitClause = "" - if pagination: - offset = (pagination.page - 1) * pagination.pageSize - limitClause = f" LIMIT {pagination.pageSize} OFFSET {offset}" - - return whereClause, orderClause, limitClause, values - - -def _getWorkflowsJoinedPaginated( - db: DatabaseConnector, - recordFilter: dict, - paginationParams: PaginationParams, -) -> dict: - """SQL listing: AutoWorkflow LEFT JOIN aggregated AutoRun stats (one query + count).""" - from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields - - wfFields = getModelFields(AutoWorkflow) - whereClause, orderClause, limitClause, values = _buildJoinedWorkflowWhereOrderLimit( - recordFilter, paginationParams, wfFields, - ) - countValues = list(values) - - fromSql = f'"AutoWorkflow" w LEFT JOIN {_RUN_STATS_SUBQUERY.strip()} ON rs."workflowId" = w."id"' - - countSql = f"SELECT COUNT(*) AS cnt FROM {fromSql}{whereClause}" - dataSql = f"SELECT w.*, rs.\"lastStartedAt\", rs.\"runCount\", rs.\"activeRunId\" FROM {fromSql}{whereClause}{orderClause}{limitClause}" - - db._ensure_connection() - with db.borrowCursor() as cursor: - cursor.execute(countSql, countValues) - totalItems = int(cursor.fetchone()["cnt"]) - - cursor.execute(dataSql, values) - rawRows = [dict(row) for row in cursor.fetchall()] - - pageSize = paginationParams.pageSize if paginationParams else max(totalItems, 1) - totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0 - - modelFields = AutoWorkflow.model_fields - for record in rawRows: - parseRecordFields(record, wfFields, "table AutoWorkflow joined listing") - for fieldName, fieldType in wfFields.items(): - if fieldType == "JSONB" and fieldName in record and record[fieldName] is None: - fieldInfo = modelFields.get(fieldName) - if fieldInfo: - fieldAnnotation = fieldInfo.annotation - if fieldAnnotation == list or ( - hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is list - ): - record[fieldName] = [] - elif fieldAnnotation == dict or ( - hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is dict - ): - record[fieldName] = {} - - return {"items": rawRows, "totalItems": totalItems, "totalPages": totalPages} - - -def _cascadeDeleteAutoWorkflow(db: DatabaseConnector, workflowId: str) -> None: - """Delete AutoWorkflow and dependent rows (same order as interfaceDbApp._cascadeDeleteGraphicalEditorData).""" - wf_id = workflowId - for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": wf_id}) or []: - vid = v.get("id") - if vid: - db.recordDelete(AutoVersion, vid) - for run in db.getRecordset(AutoRun, recordFilter={"workflowId": wf_id}) or []: - run_id = run.get("id") - if not run_id: - continue - for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": run_id}) or []: - slid = sl.get("id") - if slid: - db.recordDelete(AutoStepLog, slid) - db.recordDelete(AutoRun, run_id) - for task in db.getRecordset(AutoTask, recordFilter={"workflowId": wf_id}) or []: - tid = task.get("id") - if tid: - db.recordDelete(AutoTask, tid) - db.recordDelete(AutoWorkflow, wf_id) - - -@router.get("") -@limiter.limit("60/minute") -def get_workflow_runs( - request: Request, - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - status: Optional[str] = Query(None, description="Filter by status"), - mandateId: Optional[str] = Query(None, description="Filter by mandate"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), - mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), - column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """List workflow runs with RBAC scoping (SQL-paginated).""" - db = _getDb() - if not db._ensureTableExists(AutoRun): - if mode in ("filterValues", "ids"): - from fastapi.responses import JSONResponse - return JSONResponse(content=[]) - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - - if mode == "filterValues": - if not column: - raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - return _enrichedFilterValues(db, context, AutoRun, _scopedRunFilter, column) - - if mode == "ids": - from modules.dbHelpers.paginationHelpers import handleIdsMode - baseFilter = _scopedRunFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - return handleIdsMode(db, AutoRun, pagination, recordFilter) - - baseFilter = _scopedRunFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - - if status: - recordFilter["status"] = status - if mandateId: - recordFilter["mandateId"] = mandateId - - paginationParams = _parsePaginationOr400(pagination) - if not paginationParams: - page = (offset // limit) + 1 if limit > 0 else 1 - paginationParams = PaginationParams( - page=page, - pageSize=limit, - sort=[{"field": "startedAt", "direction": "desc"}], - ) - - from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort - result = getRecordsetPaginatedWithFkSort( - db, AutoRun, - pagination=paginationParams, - recordFilter=recordFilter if recordFilter else None, - ) - pageRuns = result.get("items", []) if isinstance(result, dict) else result.items - total = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems - - wfIds = list({r.get("workflowId") for r in pageRuns if r.get("workflowId")}) - wfMap: dict = {} - if wfIds and db._ensureTableExists(AutoWorkflow): - wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfIds}) - for wf in (wfs or []): - wfMap[wf.get("id")] = wf - - from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels, resolveUserLabels - from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface - - runs = [] - for r in pageRuns: - row = dict(r) - wfId = row.get("workflowId") - wf = wfMap.get(wfId, {}) - row["workflowLabel"] = ( - row.get("label") - or (wf.get("label") if isinstance(wf, dict) else None) - or wfId - ) - fiid = wf.get("featureInstanceId") if isinstance(wf, dict) else None - row["featureInstanceId"] = fiid - runs.append(row) - - appDb = _getRootIface().db - enrichRowsWithFkLabels( - runs, - db=db, - labelResolvers={ - "mandateId": partial(resolveMandateLabels, appDb), - "featureInstanceId": partial(resolveInstanceLabels, appDb), - "ownerId": partial(resolveUserLabels, appDb), - }, - ) - for row in runs: - row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) - row["mandateLabel"] = row.pop("mandateIdLabel", None) - row["ownerLabel"] = row.pop("ownerIdLabel", None) - - return {"runs": runs, "total": total, "limit": limit, "offset": offset} - - -@router.get("/metrics") -@limiter.limit("60/minute") -def get_workflow_metrics( - request: Request, - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Aggregated metrics across all accessible workflow runs (SQL COUNT). - - Uses the same RBAC scoping as the runs list and workflows list - so that metric cards always match the table data. - """ - db = _getDb() - - # --- Workflow counts (same filter as /workflows endpoint) --- - workflowCount = 0 - activeWorkflows = 0 - if db._ensureTableExists(AutoWorkflow): - wfBaseFilter = _scopedWorkflowFilter(context) - wfFilter = dict(wfBaseFilter) if wfBaseFilter else {} - wfFilter["isTemplate"] = False - - wfCount = db.getRecordsetPaginated( - AutoWorkflow, pagination=PaginationParams(page=1, pageSize=1), - recordFilter=wfFilter if wfFilter else None, - ) - workflowCount = wfCount.get("totalItems", 0) if isinstance(wfCount, dict) else wfCount.totalItems - - activeFilter = dict(wfFilter) - activeFilter["active"] = True - activeCount = db.getRecordsetPaginated( - AutoWorkflow, pagination=PaginationParams(page=1, pageSize=1), - recordFilter=activeFilter, - ) - activeWorkflows = activeCount.get("totalItems", 0) if isinstance(activeCount, dict) else activeCount.totalItems - - # --- Run counts (same filter as /runs endpoint) --- - if not db._ensureTableExists(AutoRun): - return { - "totalRuns": 0, "runsByStatus": {}, "totalTokens": 0, - "totalCredits": 0, "workflowCount": workflowCount, - "activeWorkflows": activeWorkflows, - } - - runBaseFilter = _scopedRunFilter(context) - - countResult = db.getRecordsetPaginated( - AutoRun, pagination=PaginationParams(page=1, pageSize=1), - recordFilter=runBaseFilter, - ) - totalRuns = countResult.get("totalItems", 0) if isinstance(countResult, dict) else countResult.totalItems - - runsByStatus: dict = {} - statusValues = db.getDistinctColumnValues(AutoRun, "status", recordFilter=runBaseFilter) - for sv in (statusValues or []): - statusFilter = dict(runBaseFilter) if runBaseFilter else {} - statusFilter["status"] = sv - sr = db.getRecordsetPaginated( - AutoRun, pagination=PaginationParams(page=1, pageSize=1), - recordFilter=statusFilter, - ) - runsByStatus[sv] = sr.get("totalItems", 0) if isinstance(sr, dict) else sr.totalItems - - totalTokens = 0 - totalCredits = 0.0 - if 0 < totalRuns <= 10000: - allRuns = db.getRecordset(AutoRun, recordFilter=runBaseFilter, fieldFilter=["costTokens", "costCredits"]) or [] - for r in allRuns: - totalTokens += r.get("costTokens", 0) or 0 - totalCredits += r.get("costCredits", 0.0) or 0.0 - - return { - "totalRuns": totalRuns, - "runsByStatus": runsByStatus, - "totalTokens": totalTokens, - "totalCredits": round(totalCredits, 4), - "workflowCount": workflowCount, - "activeWorkflows": activeWorkflows, - } - - -# --------------------------------------------------------------------------- -# System-level Workflow listing (all workflows the user can see via RBAC) -# --------------------------------------------------------------------------- - -@router.get("/workflows") -@limiter.limit("60/minute") -def get_system_workflows( - request: Request, - active: Optional[bool] = Query(None, description="Filter by active status"), - mandateId: Optional[str] = Query(None, description="Filter by mandate"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), - mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), - column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """List all workflows the user has access to (RBAC-scoped, cross-instance).""" - db = _getDb() - if not db._ensureTableExists(AutoWorkflow): - if mode in ("filterValues", "ids"): - from fastapi.responses import JSONResponse - return JSONResponse(content=[]) - return {"items": [], "pagination": {"currentPage": 1, "pageSize": 25, "totalItems": 0, "totalPages": 0}} - - if mode == "filterValues": - if not column: - raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - return _enrichedFilterValues(db, context, AutoWorkflow, _scopedWorkflowFilter, column) - - if mode == "ids": - from modules.dbHelpers.paginationHelpers import handleIdsMode - baseFilter = _scopedWorkflowFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - recordFilter["isTemplate"] = False - return handleIdsMode(db, AutoWorkflow, pagination, recordFilter) - - baseFilter = _scopedWorkflowFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - recordFilter["isTemplate"] = False - - if active is not None: - recordFilter["active"] = active - if mandateId: - recordFilter["mandateId"] = mandateId - - paginationParams = _parsePaginationOr400(pagination) - if not paginationParams: - paginationParams = PaginationParams( - page=1, - pageSize=25, - sort=[{"field": "sysCreatedAt", "direction": "desc"}], - ) - - from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels - - featureCodeMap: dict = {} - - def _resolveInstanceLabelsWithFeatureCode(ids): - from modules.interfaces.interfaceDbApp import getRootInterface as _getRI - from modules.interfaces.interfaceFeatures import getFeatureInterface - rootIf = _getRI() - featureIf = getFeatureInterface(rootIf.db) - result = {} - for iid in ids: - fi = featureIf.getFeatureInstance(iid) - if fi: - result[iid] = fi.label or None - featureCodeMap[iid] = fi.featureCode - else: - logger.warning("getSystemWorkflows: feature-instance not found for id=%s", iid) - result[iid] = None - return result - - userId = str(context.user.id) if context.user else None - adminMandateIds = [] - if userId and not context.isPlatformAdmin: - userMandateIds = _getUserMandateIds(userId) - adminMandateIds = _getAdminMandateIds(userId, userMandateIds) - - from modules.dbHelpers.fkLabelResolver import resolveUserLabels as _resolveUserLabels - from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface - - fkSortField = _firstFkSortFieldForWorkflows(paginationParams) - if fkSortField: - from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort - _COMPUTED_FIELDS = {"lastStartedAt", "runCount", "isRunning"} - hasComputedFilter = bool( - paginationParams.filters - and any(k in _COMPUTED_FIELDS for k in paginationParams.filters) - ) - hasComputedSort = any( - (s.field if hasattr(s, "field") else s.get("field", "")) in _COMPUTED_FIELDS - for s in (paginationParams.sort or []) - ) - dbPagination = paginationParams - if hasComputedFilter or hasComputedSort: - dbFilters = { - k: v for k, v in (paginationParams.filters or {}).items() - if k not in _COMPUTED_FIELDS - } or None - dbSort = [ - s for s in (paginationParams.sort or []) - if (s.field if hasattr(s, "field") else s.get("field", "")) not in _COMPUTED_FIELDS - ] - dbPagination = PaginationParams.model_construct( - page=1, - pageSize=9999, - sort=dbSort or [{"field": "sysCreatedAt", "direction": "desc"}], - filters=dbFilters, - ) - result = getRecordsetPaginatedWithFkSort( - db, AutoWorkflow, - pagination=dbPagination, - recordFilter=recordFilter if recordFilter else None, - ) - pageItems = result.get("items", []) if isinstance(result, dict) else result.items - workflowIds = [w.get("id") for w in pageItems if w.get("id")] - statsById = _batchRunStatsForWorkflowIds(db, workflowIds) - items = [] - for w in pageItems: - row = dict(w) - wfId = row.get("id") - st = statsById.get(str(wfId)) if wfId else None - activeRunId = st.get("activeRunId") if st else None - row["isRunning"] = bool(activeRunId) - row["activeRunId"] = activeRunId - row["runCount"] = int(st.get("runCount") or 0) if st else 0 - row["lastStartedAt"] = float(st["lastStartedAt"]) if st and st.get("lastStartedAt") is not None else None - wMandateId = row.get("mandateId") - if context.isPlatformAdmin: - row["canEdit"] = True - row["canDelete"] = True - row["canExecute"] = True - elif wMandateId and wMandateId in adminMandateIds: - row["canEdit"] = True - row["canDelete"] = True - row["canExecute"] = True - else: - row["canEdit"] = False - row["canDelete"] = False - row["canExecute"] = False - row.pop("graph", None) - items.append(row) - _appDb = _getRootIface().db - enrichRowsWithFkLabels( - items, - db=db, - labelResolvers={ - "mandateId": partial(resolveMandateLabels, _appDb), - "featureInstanceId": _resolveInstanceLabelsWithFeatureCode, - "ownerId": partial(_resolveUserLabels, _appDb), - }, - ) - for row in items: - row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) - row["mandateLabel"] = row.pop("mandateIdLabel", None) - row["ownerLabel"] = row.pop("ownerIdLabel", None) - row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId")) - if hasComputedFilter or hasComputedSort: - computedFilters = { - k: v for k, v in (paginationParams.filters or {}).items() - if k in _COMPUTED_FIELDS - } - computedSort = [ - s for s in (paginationParams.sort or []) - if (s.field if hasattr(s, "field") else s.get("field", "")) in _COMPUTED_FIELDS - ] - computedPagination = PaginationParams.model_construct( - page=paginationParams.page, - pageSize=paginationParams.pageSize, - sort=computedSort or [], - filters=computedFilters or None, - ) - filtered = applyFiltersAndSort(items, computedPagination) - totalItems = filtered.get("totalItems", len(items)) - totalPages = filtered.get("totalPages", 1) - items = filtered.get("items", items) - else: - totalItems = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems - totalPages = result.get("totalPages", 0) if isinstance(result, dict) else result.totalPages - else: - result = _getWorkflowsJoinedPaginated( - db, recordFilter if recordFilter else {}, paginationParams, - ) - pageItems = result.get("items", []) - totalItems = result.get("totalItems", 0) - totalPages = result.get("totalPages", 0) - items = [] - for row in pageItems: - wMandateId = row.get("mandateId") - wfId = row.get("id") - activeRunId = row.get("activeRunId") - if row.get("runCount") is not None: - row["runCount"] = int(row["runCount"]) - row["isRunning"] = bool(activeRunId) - if context.isPlatformAdmin: - row["canEdit"] = True - row["canDelete"] = True - row["canExecute"] = True - elif wMandateId and wMandateId in adminMandateIds: - row["canEdit"] = True - row["canDelete"] = True - row["canExecute"] = True - else: - row["canEdit"] = False - row["canDelete"] = False - row["canExecute"] = False - row.pop("graph", None) - items.append(row) - _appDb2 = _getRootIface().db - enrichRowsWithFkLabels( - items, - db=db, - labelResolvers={ - "mandateId": partial(resolveMandateLabels, _appDb2), - "featureInstanceId": _resolveInstanceLabelsWithFeatureCode, - "ownerId": partial(_resolveUserLabels, _appDb2), - }, - ) - for row in items: - row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) - row["mandateLabel"] = row.pop("mandateIdLabel", None) - row["ownerLabel"] = row.pop("ownerIdLabel", None) - row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId")) - - return { - "items": items, - "pagination": { - "currentPage": paginationParams.page, - "pageSize": paginationParams.pageSize, - "totalItems": totalItems, - "totalPages": totalPages, - }, - } - - -@router.delete("/workflows/{workflowId}") -@limiter.limit("30/minute") -def delete_system_workflow( - request: Request, - workflowId: str = Path(..., description="AutoWorkflow ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """ - Delete a workflow by ID without requiring featureInstanceId (orphan / broken FK rows). - RBAC matches get_system_workflows: SysAdmin or Mandate-Admin for the workflow's mandate. - Cascades versions, runs, step logs, tasks — same as mandate cascade delete. - """ - db = _getDb() - if not db._ensureTableExists(AutoWorkflow): - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - - rows = db.getRecordset(AutoWorkflow, recordFilter={"id": workflowId}) - if not rows: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - - wf = dict(rows[0]) if rows else {} - if wf.get("isTemplate"): - raise HTTPException(status_code=400, detail=routeApiMsg("Cannot delete a template workflow here")) - - wf_mandate_id = wf.get("mandateId") - if not _userMayDeleteWorkflow(context, wf_mandate_id): - raise HTTPException(status_code=403, detail=routeApiMsg("Not allowed to delete this workflow")) - - try: - _cascadeDeleteAutoWorkflow(db, workflowId) - except Exception as e: - logger.error(f"delete_system_workflow cascade failed: {e}") - raise HTTPException(status_code=500, detail=routeApiMsg(str(e))) - - # Callback registry: log + propagate so listener bugs are visible. - # Cascade is already committed at this point — failure here is a side-effect - # bug (stale caches, missed notifications), never a "ignore silently" event. - try: - from modules.shared.callbackRegistry import callbackRegistry - callbackRegistry.trigger("graphicalEditor.workflow.changed") - except Exception as e: - logger.error( - f"delete_system_workflow: callbackRegistry.trigger failed for " - f"workflowId={workflowId}: {e}" - ) - raise HTTPException( - status_code=500, - detail=routeApiMsg(f"Workflow deleted but post-delete callback failed: {e}"), - ) - - return {"success": True, "id": workflowId} - - -# --------------------------------------------------------------------------- -# Filter-values endpoints (for FormGeneratorTable column filters) -# --------------------------------------------------------------------------- - -_SYNTHETIC_TIMESTAMP_FIELDS = {"lastStartedAt"} - - -def _isTimestampColumn(modelClass, column: str) -> bool: - """Check if a column is a timestamp field (PeriodPicker, no discrete values needed).""" - if column in _SYNTHETIC_TIMESTAMP_FIELDS: - return True - fields = getattr(modelClass, "model_fields", {}) - fieldInfo = fields.get(column) - if not fieldInfo: - return False - extra = getattr(fieldInfo, "json_schema_extra", None) - if isinstance(extra, dict): - return extra.get("frontend_type") == "timestamp" - return False - - -def _enrichedFilterValues( - db, context: RequestContext, modelClass, scopeFilter, column: str, -): - """Return distinct filter values for FormGeneratorTable column filters. - - For FK columns (mandateId, featureInstanceId) returns ``{value, label}`` - objects so the frontend can display human-readable labels in the dropdown - without a separate source fk fetch. Non-FK columns return ``string | null``. - - Timestamp columns (sysCreatedAt, lastStartedAt) return an empty list because - the frontend uses a PeriodPicker (range selector) — no discrete values needed. - - ``null`` is included when rows with NULL/empty values exist (enables the - "(Leer)" filter option). - - Returns JSONResponse to bypass FastAPI response_model validation. - """ - from fastapi.responses import JSONResponse - from modules.dbHelpers.fkLabelResolver import resolveMandateLabels, resolveInstanceLabels - - if _isTimestampColumn(modelClass, column): - return JSONResponse(content=[]) - - if column in ("mandateLabel", "mandateId"): - baseFilter = scopeFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - if modelClass == AutoWorkflow: - recordFilter["isTemplate"] = False - items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["mandateId"]) or [] - allVals = {r.get("mandateId") for r in items} - mandateIds = sorted(v for v in allVals if v) - hasEmpty = None in allVals or "" in allVals - labelMap = resolveMandateLabels(db, mandateIds) if mandateIds else {} - result = [{"value": mid, "label": labelMap.get(mid) or f"NA({mid})"} for mid in mandateIds] - if hasEmpty: - result.append(None) - return JSONResponse(content=result) - - if column in ("instanceLabel", "featureInstanceId"): - baseFilter = scopeFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - if modelClass == AutoWorkflow: - recordFilter["isTemplate"] = False - items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["featureInstanceId"]) or [] - allVals = {r.get("featureInstanceId") for r in items} - instanceIds = sorted(v for v in allVals if v) - hasEmpty = None in allVals or "" in allVals - else: - items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["workflowId"]) or [] - wfIds = list({r.get("workflowId") for r in items if r.get("workflowId")}) - instanceIds = [] - hasEmpty = False - if wfIds and db._ensureTableExists(AutoWorkflow): - wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfIds}, fieldFilter=["featureInstanceId"]) or [] - allVals = {w.get("featureInstanceId") for w in wfs} - instanceIds = sorted(v for v in allVals if v) - hasEmpty = None in allVals or "" in allVals - labelMap = resolveInstanceLabels(db, instanceIds) if instanceIds else {} - result = [{"value": iid, "label": labelMap.get(iid) or f"NA({iid})"} for iid in instanceIds] - if hasEmpty: - result.append(None) - return JSONResponse(content=result) - - if column == "workflowLabel": - baseFilter = scopeFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["workflowId", "label"]) or [] - labels = set() - wfIds = set() - hasEmpty = False - for r in items: - if r.get("label"): - labels.add(r["label"]) - elif not r.get("workflowId"): - hasEmpty = True - if r.get("workflowId"): - wfIds.add(r["workflowId"]) - if wfIds and db._ensureTableExists(AutoWorkflow): - wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": list(wfIds)}, fieldFilter=["label"]) or [] - for wf in wfs: - if wf.get("label"): - labels.add(wf["label"]) - result = sorted(labels, key=lambda v: v.lower()) - if hasEmpty: - result.append(None) - return JSONResponse(content=result) - - baseFilter = scopeFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - if modelClass == AutoWorkflow: - recordFilter["isTemplate"] = False - return JSONResponse(content=db.getDistinctColumnValues(modelClass, column, recordFilter=recordFilter or None) or []) - - - - - - -# --------------------------------------------------------------------------- -# Run-specific endpoints (path-param routes MUST come after static routes) -# --------------------------------------------------------------------------- - -@router.get("/{runId}/steps") -@limiter.limit("60/minute") -def get_run_steps( - request: Request, - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Get step logs for a specific run (with access check).""" - db = _getDb() - if not db._ensureTableExists(AutoRun): - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - - runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) - if not runs: - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - run = dict(runs[0]) - - if not context.isPlatformAdmin: - userId = str(context.user.id) if context.user else None - runOwner = run.get("ownerId") - runMandate = run.get("mandateId") - - if runOwner == userId: - pass - elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): - pass - else: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) - - if not db._ensureTableExists(AutoStepLog): - return {"steps": []} - - records = 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} - - -# --------------------------------------------------------------------------- -# SSE stream for live run tracing (system-level, no instanceId required) -# --------------------------------------------------------------------------- - -@router.get("/{runId}/stream") -async def get_run_stream( - request: Request, - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -): - """SSE stream for live step-log updates during a workflow run (system-level).""" - db = _getDb() - if not db._ensureTableExists(AutoRun): - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - - runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) - if not runs: - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - run = dict(runs[0]) - - if not context.isPlatformAdmin: - userId = str(context.user.id) if context.user else None - runOwner = run.get("ownerId") - runMandate = run.get("mandateId") - if runOwner == userId: - pass - elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): - pass - else: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) - - 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", - }, - ) - - -@router.post("/{runId}/stop") -@limiter.limit("30/minute") -def stop_workflow_run( - request: Request, - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -): - """Stop a running workflow execution (system-level).""" - db = _getDb() - if not db._ensureTableExists(AutoRun): - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - - runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) - if not runs: - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - run = dict(runs[0]) - - if not context.isPlatformAdmin: - userId = str(context.user.id) if context.user else None - runOwner = run.get("ownerId") - runMandate = run.get("mandateId") - if runOwner == userId: - pass - elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): - pass - else: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) - - from modules.workflows.automation2.executionEngine import requestRunStop - flagged = requestRunStop(runId) - - if not flagged: - currentStatus = run.get("status", "") - if currentStatus in ("completed", "failed", "stopped"): - return {"status": currentStatus, "runId": runId, "message": "Run already finished"} - stopUpdates = {"status": "stopped"} - if not run.get("completedAt"): - stopUpdates["completedAt"] = time.time() - db.recordModify(AutoRun, runId, stopUpdates) - return {"status": "stopped", "runId": runId, "message": "Run not active in memory, marked as stopped"} - - return {"status": "stopping", "runId": runId, "message": "Stop signal sent"} diff --git a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py index 25099bf8..9c94247d 100644 --- a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py +++ b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py @@ -112,7 +112,7 @@ class AgentConfig(BaseModel): default=False, description=( "If True, do NOT register workflow-action methods as agent tools. " - "Used by editor-style agents (e.g. GraphicalEditor) that should only " + "Used by editor-style agents (e.g. WorkflowAutomation) that should only " "manipulate the workflow graph, not execute its actions." ), ) diff --git a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py index bcfaff26..a464525a 100644 --- a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py @@ -210,7 +210,7 @@ def _registerDefaultToolboxes() -> None: id="workflow", label="Workflow", description="Graph manipulation tools for the visual editor", - featureCode="graphicalEditor", + featureCode="workflowAutomation", isDefault=False, tools=[ "readWorkflowGraph", "addNode", "removeNode", "connectNodes", diff --git a/modules/serviceCenter/services/serviceAgent/workflowTools.py b/modules/serviceCenter/services/serviceAgent/workflowTools.py index 32defa2b..c1d3bf1e 100644 --- a/modules/serviceCenter/services/serviceAgent/workflowTools.py +++ b/modules/serviceCenter/services/serviceAgent/workflowTools.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Workflow Toolbox - AI-assisted graph manipulation tools for the GraphicalEditor. +Workflow Toolbox - AI-assisted graph manipulation tools for WorkflowAutomation. Tools: readWorkflowGraph, addNode, removeNode, connectNodes, setNodeParameter, listAvailableNodeTypes, describeNodeType, autoLayoutWorkflow, validateGraph, listWorkflowHistory, readWorkflowMessages. @@ -89,9 +89,8 @@ def _resolveMandateId(context: Any) -> str: def _getInterface(context: Any, instanceId: str): - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface - return getGraphicalEditorInterface(_resolveUser(context), _resolveMandateId(context), instanceId) + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + return _getWorkflowAutomationInterface(_resolveUser(context), _resolveMandateId(context), instanceId) async def _readWorkflowGraph(params: Dict[str, Any], context: Any) -> ToolResult: @@ -307,8 +306,7 @@ async def _list_upstream_paths(params: Dict[str, Any], context: Any) -> ToolResu return _err(name, f"Workflow {workflow_id} not found") graph = wf.get("graph", {}) or {} - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths + from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(node_id)) return _ok(name, {"paths": paths}) @@ -438,8 +436,7 @@ async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolR """ name = "listAvailableNodeTypes" try: - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES nodeTypes = [] for n in STATIC_NODE_TYPES: if not isinstance(n, dict): @@ -465,8 +462,7 @@ async def _describeNodeType(params: Dict[str, Any], context: Any) -> ToolResult: nodeType = params.get("nodeType") or params.get("id") if not nodeType: return _err(name, "nodeType required") - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES target: Dict[str, Any] = {} for n in STATIC_NODE_TYPES: if isinstance(n, dict) and n.get("id") == nodeType: @@ -879,8 +875,7 @@ async def _exportWorkflowToFile(params: Dict[str, Any], context: Any) -> ToolRes envelope = iface.exportWorkflowToDict(workflowId) if envelope is None: return _err(name, f"Workflow {workflowId} not found") - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor._workflowFileSchema import buildFileName + from modules.workflowAutomation.editor._workflowFileSchema import buildFileName return _ok(name, { "fileName": buildFileName(envelope.get("label", "workflow")), "envelope": envelope, diff --git a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionGraphicalEditorRunFailed.py b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py similarity index 91% rename from modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionGraphicalEditorRunFailed.py rename to modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py index 2d77fd5b..b1cfecd0 100644 --- a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionGraphicalEditorRunFailed.py +++ b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Subscription handler for GraphicalEditor workflow run failures. +Subscription handler for WorkflowAutomation workflow run failures. Sends email notifications to subscribed users when a workflow run fails. """ @@ -20,7 +20,7 @@ def execute( messagingService, ) -> MessagingSubscriptionExecutionResult: """ - Subscription function for GraphicalEditor run failures. + Subscription function for WorkflowAutomation run failures. Sends email/SMS to registered users when a workflow run fails. """ triggerData = eventParameters.triggerData or {} @@ -40,7 +40,7 @@ def execute( f"Workflow-ID: {workflowId}\n" f"Run-ID: {runId}\n" f"Fehler: {error}\n\n" - f"Bitte prüfen Sie den Workflow im Grafischen Editor." + f"Bitte prüfen Sie den Workflow in der Workflow-Automation." ) smsMessage = f"Workflow '{workflowLabel}' fehlgeschlagen: {error[:100]}" diff --git a/modules/shared/workflowAutomationHelpers.py b/modules/shared/workflowAutomationHelpers.py new file mode 100644 index 00000000..4813c087 --- /dev/null +++ b/modules/shared/workflowAutomationHelpers.py @@ -0,0 +1,624 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Shared helpers for WorkflowAutomation route files. + +Extracted from routeWorkflowDashboard.py and routeWorkflowAutomation.py to +avoid code duplication across route files. Contains DB access, RBAC scoping, +pagination helpers, and FK label resolver setup. +""" + +import json +import logging +import math +import re +from datetime import datetime, timezone +from typing import Optional, List, Dict, Any + +from fastapi import HTTPException + +from modules.auth.authentication import RequestContext +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict +from modules.datamodels.datamodelWorkflowAutomation import ( + AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion, + GRAPHICAL_EDITOR_DATABASE, +) +from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface +from modules.shared.configuration import APP_CONFIG + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# DB access +# --------------------------------------------------------------------------- + +def _getWorkflowAutomationDb() -> DatabaseConnector: + """Get a DatabaseConnector for the WorkflowAutomation (graphicaleditor) DB.""" + return DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase=GRAPHICAL_EDITOR_DATABASE, + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + +def _getAppDb() -> DatabaseConnector: + """Get the root interface DB (poweron_app) for FK label resolution.""" + return _getRootIface().db + + +# --------------------------------------------------------------------------- +# RBAC helpers +# --------------------------------------------------------------------------- + +def _getUserMandateIds(userId: str) -> List[str]: + """Get mandate IDs the user is a member of.""" + rootIface = _getRootIface() + memberships = rootIface.getUserMandates(userId) + return [um.mandateId for um in memberships if um.mandateId and um.enabled] + + +def _getAdminMandateIds(userId: str, mandateIds: List[str]) -> List[str]: + """Batch-check which mandates the user is admin for.""" + if not mandateIds: + return [] + rootIface = _getRootIface() + from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole + + memberships = rootIface.db.getRecordset( + UserMandate, + recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True}, + ) + if not memberships: + return [] + + umIdToMandateId: Dict[str, str] = {} + for m in memberships: + row = m if isinstance(m, dict) else m.__dict__ + um_id = row.get("id") + mid = row.get("mandateId") + if um_id and mid: + umIdToMandateId[str(um_id)] = str(mid) + + userMandateIds = list(umIdToMandateId.keys()) + allRoles = rootIface.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateIds}, + ) + if not allRoles: + return [] + + roleIds: set = set() + roleToMandate: Dict[str, set] = {} + for r in allRoles: + row = r if isinstance(r, dict) else r.__dict__ + rid = row.get("roleId") + um_id = row.get("userMandateId") + mid = umIdToMandateId.get(str(um_id)) if um_id else None + if rid and mid: + roleIds.add(rid) + roleToMandate.setdefault(rid, set()).add(mid) + + if not roleIds: + return [] + + from modules.datamodels.datamodelRbac import Role + roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)}) + adminMandates: set = set() + for role in (roleRecords or []): + row = role if isinstance(role, dict) else role.__dict__ + rid = row.get("id") + if not rid or rid not in roleToMandate: + continue + if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"): + adminMandates.update(roleToMandate[rid]) + + return [mid for mid in mandateIds if mid in adminMandates] + + +def _isUserMandateAdmin(userId: str, mandateId: str) -> bool: + """Check if user is admin for a specific mandate.""" + return mandateId in _getAdminMandateIds(userId, [mandateId]) + + +def _scopedWorkflowFilter(context: RequestContext) -> Optional[Dict[str, Any]]: + """Build DB filter for listing workflows: mandate-scoped for members, None for sysadmin.""" + if context.isPlatformAdmin: + return None + userId = str(context.user.id) if context.user else None + if not userId: + return {"mandateId": "__impossible__"} + mandateIds = _getUserMandateIds(userId) + if mandateIds: + return {"mandateId": mandateIds} + return {"mandateId": "__impossible__"} + + +def _scopedRunFilter(context: RequestContext) -> Optional[Dict[str, Any]]: + """Build DB filter for listing runs: admin sees mandate runs, user sees own.""" + if context.isPlatformAdmin: + return None + userId = str(context.user.id) if context.user else None + if not userId: + return {"ownerId": "__impossible__"} + mandateIds = _getUserMandateIds(userId) + adminMandateIds = _getAdminMandateIds(userId, mandateIds) + if adminMandateIds: + return {"mandateId": adminMandateIds} + return {"ownerId": userId} + + +def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool: + """Check if user may delete a workflow in the given mandate.""" + if context.isPlatformAdmin: + return True + userId = str(context.user.id) if context.user else None + if not userId or not wfMandateId: + return False + userMandateIds = _getUserMandateIds(userId) + adminMandateIds = _getAdminMandateIds(userId, userMandateIds) + return wfMandateId in adminMandateIds + + +def _validateWorkflowAccess( + context: RequestContext, + workflow: Optional[Dict[str, Any]], + action: str = "read", +) -> None: + """Validate access to a workflow. Raises HTTPException(403) on denial. + + Actions: + - 'read': mandate membership + - 'write'/'delete': mandate admin + - 'execute': mandate membership + FeatureAccess on targetInstanceId + """ + if context.isPlatformAdmin: + return + + userId = str(context.user.id) if context.user else None + if not userId: + raise HTTPException(status_code=403, detail="Authentication required") + + if workflow is None: + raise HTTPException(status_code=404, detail="Workflow not found") + + wfMandateId = workflow.get("mandateId") or "" + if not wfMandateId: + if action == "read": + return + raise HTTPException(status_code=403, detail="Workflow has no mandate — admin only") + + userMandateIds = _getUserMandateIds(userId) + if wfMandateId not in userMandateIds: + raise HTTPException(status_code=403, detail="Not a member of the workflow's mandate") + + if action == "read": + return + + if action == "execute": + targetInstanceId = workflow.get("targetFeatureInstanceId") + if targetInstanceId: + from modules.interfaces.interfaceFeatureAccess import _hasFeatureAccess + if _hasFeatureAccess(userId, targetInstanceId): + return + + adminMandateIds = _getAdminMandateIds(userId, [wfMandateId]) + if wfMandateId not in adminMandateIds: + raise HTTPException( + status_code=403, + detail=f"Mandate admin required for '{action}' on workflows", + ) + + +# --------------------------------------------------------------------------- +# Pagination +# --------------------------------------------------------------------------- + +def _parsePaginationOr400(pagination: Optional[str]) -> Optional[PaginationParams]: + """Parse a JSON pagination query string. Raises 400 on parse errors.""" + if not pagination: + return None + try: + paginationDict = json.loads(pagination) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=400, + detail=f"Invalid 'pagination' query: not valid JSON ({e.msg})", + ) + if not paginationDict: + return None + try: + paginationDict = normalize_pagination_dict(paginationDict) + return PaginationParams(**paginationDict) + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Invalid 'pagination' payload: {e}", + ) + + +# --------------------------------------------------------------------------- +# FK label resolver setup (cross-DB: poweron_app vs poweron_graphicaleditor) +# --------------------------------------------------------------------------- + +def _resolveFkLabels(rows: list, model, labelResolvers: Optional[dict] = None) -> list: + """Resolve FK labels for a list of rows using the app DB for user/mandate/instance lookups.""" + if not rows: + return rows + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + appDb = _getAppDb() + enrichRowsWithFkLabels(rows, model, db=appDb, labelResolvers=labelResolvers) + return rows + + +def _buildStandardLabelResolvers() -> dict: + """Standard FK label resolvers for mandateId, featureInstanceId, ownerId.""" + from modules.dbHelpers.fkLabelResolver import ( + resolveMandateLabels, + resolveInstanceLabels, + resolveUserLabels, + ) + appDb = _getAppDb() + return { + "mandateId": lambda ids: resolveMandateLabels(ids, db=appDb), + "featureInstanceId": lambda ids: resolveInstanceLabels(ids, db=appDb), + "ownerId": lambda ids: resolveUserLabels(ids, db=appDb), + "sysCreatedBy": lambda ids: resolveUserLabels(ids, db=appDb), + } + + +# --------------------------------------------------------------------------- +# Cascade delete +# --------------------------------------------------------------------------- + +def _cascadeDeleteWorkflow(db: DatabaseConnector, workflowId: str) -> None: + """Delete AutoWorkflow and all dependent rows (versions, runs, step logs, tasks).""" + for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId}) or []: + vid = v.get("id") + if vid: + db.recordDelete(AutoVersion, vid) + for run in db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []: + runId = run.get("id") + if not runId: + continue + for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: + slid = sl.get("id") + if slid: + db.recordDelete(AutoStepLog, slid) + db.recordDelete(AutoRun, runId) + for task in db.getRecordset(AutoTask, recordFilter={"workflowId": workflowId}) or []: + tid = task.get("id") + if tid: + db.recordDelete(AutoTask, tid) + db.recordDelete(AutoWorkflow, workflowId) + + +# --------------------------------------------------------------------------- +# SQL join helpers for workflow listing with run stats +# --------------------------------------------------------------------------- + +_RUN_STATS_SUBQUERY = """ +( + SELECT s."workflowId" AS "workflowId", + MAX(COALESCE(s."startedAt", s."sysCreatedAt")) AS "lastStartedAt", + COUNT(s."id")::bigint AS "runCount", + MAX(CASE WHEN s."status" IN ('running', 'paused') THEN s."id" END) AS "activeRunId" + FROM "AutoRun" s + GROUP BY s."workflowId" +) rs +""" + + +def _firstFkSortFieldForWorkflows(pagination) -> Optional[str]: + """First sort field that requires FK label resolution (cross-DB), or None.""" + from modules.dbHelpers.fkLabelResolver import buildLabelResolversFromModel + if not pagination or not pagination.sort: + return None + resolvers = buildLabelResolversFromModel(AutoWorkflow) + if not resolvers: + return None + for sf in pagination.sort: + sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) + if sfField and sfField in resolvers: + return sfField + return None + + +def _batchRunStatsForWorkflowIds(db: DatabaseConnector, workflowIds: List[str]) -> dict: + """One grouped query: lastStartedAt, runCount, activeRunId per workflow.""" + if not workflowIds or not db._ensureTableExists(AutoRun): + return {} + db._ensure_connection() + sql = """ +SELECT "workflowId", + MAX(COALESCE("startedAt", "sysCreatedAt")) AS "lastStartedAt", + COUNT("id")::bigint AS "runCount", + MAX(CASE WHEN "status" IN ('running', 'paused') THEN "id" END) AS "activeRunId" +FROM "AutoRun" +WHERE "workflowId" = ANY(%s) +GROUP BY "workflowId" +""" + out: dict = {} + with db.borrowCursor() as cursor: + cursor.execute(sql, (workflowIds,)) + for row in cursor.fetchall(): + r = dict(row) + wid = r.get("workflowId") + if wid: + out[str(wid)] = r + return out + + +def _listingColSql(key: str, wfFieldNames: set) -> Optional[str]: + if key == "lastStartedAt": + return 'rs."lastStartedAt"' + if key == "runCount": + return 'COALESCE(rs."runCount", 0::bigint)' + if key == "isRunning": + return '(rs."activeRunId" IS NOT NULL)' + if key in wfFieldNames: + return f'w."{key}"' + return None + + +def _listingOrderExpr(key: str, wfFieldNames: set, wfFields: dict) -> Optional[str]: + if key == "lastStartedAt": + return 'rs."lastStartedAt"' + if key == "runCount": + return 'COALESCE(rs."runCount", 0::bigint)' + if key == "isRunning": + return 'CASE WHEN rs."activeRunId" IS NOT NULL THEN 1 ELSE 0 END' + if key in wfFieldNames: + colType = wfFields.get(key, "TEXT") + if colType == "BOOLEAN": + return f'COALESCE(w."{key}", FALSE)' + return f'w."{key}"' + return None + + +def _appendJoinedListingFilters(whereParts: list, values: list, pagination, wfFields: dict) -> None: + """Append WHERE fragments for joined workflow listing (w + rs).""" + wfFieldNames = set(wfFields.keys()) + validCols = wfFieldNames | {"lastStartedAt", "runCount", "isRunning"} + + if not pagination or not pagination.filters: + return + + for key, val in pagination.filters.items(): + if key == "search" and isinstance(val, str) and val.strip(): + term = f"%{val.strip()}%" + textCols = [c for c, t in wfFields.items() if t == "TEXT"] + if textCols: + orParts = [f'COALESCE(w."{c}"::TEXT, \'\') ILIKE %s' for c in textCols] + whereParts.append(f"({' OR '.join(orParts)})") + values.extend([term] * len(textCols)) + continue + + if key not in validCols: + continue + + if key == "isRunning": + if isinstance(val, dict): + op = val.get("operator", "equals") + v = val.get("value", "") + isTrue = str(v).lower() == "true" + if op in ("equals", "eq"): + whereParts.append('(rs."activeRunId" IS NOT NULL)' if isTrue else '(rs."activeRunId" IS NULL)') + elif val is None: + whereParts.append('(rs."activeRunId" IS NULL)') + else: + whereParts.append( + '(rs."activeRunId" IS NOT NULL)' if str(val).lower() == "true" else '(rs."activeRunId" IS NULL)' + ) + continue + + colRef = _listingColSql(key, wfFieldNames) + if not colRef: + continue + + colType = wfFields.get(key, "TEXT") if key in wfFieldNames else ( + "DOUBLE PRECISION" if key == "lastStartedAt" else "BIGINT" if key == "runCount" else "TEXT" + ) + + if val is None: + if key == "lastStartedAt": + whereParts.append(f'({colRef} IS NULL)') + elif key == "runCount": + whereParts.append(f'({colRef} = 0)') + else: + whereParts.append(f'({colRef} IS NULL OR {colRef}::TEXT = \'\')') + continue + + if not isinstance(val, dict): + if colType == "BOOLEAN" or key == "isRunning": + whereParts.append(f'COALESCE({colRef}, FALSE) = %s') + values.append(str(val).lower() == "true") + else: + whereParts.append(f'{colRef}::TEXT ILIKE %s') + values.append(str(val)) + continue + + op = val.get("operator", "equals") + v = val.get("value", "") + if op in ("equals", "eq"): + if colType == "BOOLEAN": + whereParts.append(f'COALESCE({colRef}, FALSE) = %s') + values.append(str(v).lower() == "true") + else: + whereParts.append(f'{colRef}::TEXT = %s') + values.append(str(v)) + elif op == "contains": + whereParts.append(f'{colRef}::TEXT ILIKE %s') + values.append(f"%{v}%") + elif op == "startsWith": + whereParts.append(f'{colRef}::TEXT ILIKE %s') + values.append(f"{v}%") + elif op == "endsWith": + whereParts.append(f'{colRef}::TEXT ILIKE %s') + values.append(f"%{v}") + elif op in ("gt", "gte", "lt", "lte"): + sqlOp = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op] + if colType in ("INTEGER", "DOUBLE PRECISION", "BIGINT") or key in ("lastStartedAt", "runCount"): + try: + whereParts.append(f'{colRef}::double precision {sqlOp} %s') + values.append(float(v)) + except (ValueError, TypeError): + continue + else: + whereParts.append(f'{colRef}::TEXT {sqlOp} %s') + values.append(str(v)) + elif op == "between": + fromVal = v.get("from", "") if isinstance(v, dict) else "" + toVal = v.get("to", "") if isinstance(v, dict) else "" + if not fromVal and not toVal: + continue + isNumericCol = colType in ("INTEGER", "DOUBLE PRECISION", "BIGINT") or key in ("lastStartedAt", "runCount") + isDateVal = bool(fromVal and re.match(r"^\d{4}-\d{2}-\d{2}$", str(fromVal))) or bool( + toVal and re.match(r"^\d{4}-\d{2}-\d{2}$", str(toVal)) + ) + if isNumericCol and isDateVal: + if fromVal and toVal: + fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() + toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( + hour=23, minute=59, second=59, tzinfo=timezone.utc + ).timestamp() + whereParts.append(f"({colRef} >= %s AND {colRef} <= %s)") + values.extend([fromTs, toTs]) + elif fromVal: + fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() + whereParts.append(f"({colRef} >= %s)") + values.append(fromTs) + else: + toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( + hour=23, minute=59, second=59, tzinfo=timezone.utc + ).timestamp() + whereParts.append(f"({colRef} <= %s)") + values.append(toTs) + elif isNumericCol: + try: + if fromVal and toVal: + whereParts.append( + f"({colRef}::double precision >= %s AND {colRef}::double precision <= %s)" + ) + values.extend([float(fromVal), float(toVal)]) + elif fromVal: + whereParts.append(f"{colRef}::double precision >= %s") + values.append(float(fromVal)) + elif toVal: + whereParts.append(f"{colRef}::double precision <= %s") + values.append(float(toVal)) + except (ValueError, TypeError): + continue + else: + if fromVal and toVal: + whereParts.append(f"({colRef}::TEXT >= %s AND {colRef}::TEXT <= %s)") + values.extend([str(fromVal), str(toVal)]) + elif fromVal: + whereParts.append(f"{colRef}::TEXT >= %s") + values.append(str(fromVal)) + elif toVal: + whereParts.append(f"{colRef}::TEXT <= %s") + values.append(str(toVal)) + + +def _buildJoinedWorkflowWhereOrderLimit( + recordFilter: dict, + pagination, + wfFields: dict, +) -> tuple: + """WHERE / ORDER BY / LIMIT for joined AutoWorkflow + run stats listing.""" + wfFieldNames = set(wfFields.keys()) + whereParts: list = [] + values: list = [] + + for field, value in (recordFilter or {}).items(): + if value is None: + whereParts.append(f'w."{field}" IS NULL') + elif isinstance(value, list): + whereParts.append(f'w."{field}" = ANY(%s)') + values.append(value) + else: + whereParts.append(f'w."{field}" = %s') + values.append(value) + + _appendJoinedListingFilters(whereParts, values, pagination, wfFields) + + whereClause = " WHERE " + " AND ".join(whereParts) if whereParts else "" + + orderParts: list = [] + if pagination and pagination.sort: + for sf in pagination.sort: + sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) + sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc") + if not sfField: + continue + expr = _listingOrderExpr(sfField, wfFieldNames, wfFields) + if not expr: + continue + direction = "DESC" if str(sfDir).lower() == "desc" else "ASC" + orderParts.append(f"{expr} {direction} NULLS LAST") + if not orderParts: + orderParts.append('w."sysCreatedAt" DESC NULLS LAST') + + orderClause = " ORDER BY " + ", ".join(orderParts) + + limitClause = "" + if pagination: + offset = (pagination.page - 1) * pagination.pageSize + limitClause = f" LIMIT {pagination.pageSize} OFFSET {offset}" + + return whereClause, orderClause, limitClause, values + + +def _getWorkflowsJoinedPaginated( + db: DatabaseConnector, + recordFilter: dict, + paginationParams: PaginationParams, +) -> dict: + """SQL listing: AutoWorkflow LEFT JOIN aggregated AutoRun stats (one query + count).""" + from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields + + wfFields = getModelFields(AutoWorkflow) + whereClause, orderClause, limitClause, values = _buildJoinedWorkflowWhereOrderLimit( + recordFilter, paginationParams, wfFields, + ) + countValues = list(values) + + fromSql = f'"AutoWorkflow" w LEFT JOIN {_RUN_STATS_SUBQUERY.strip()} ON rs."workflowId" = w."id"' + + countSql = f"SELECT COUNT(*) AS cnt FROM {fromSql}{whereClause}" + dataSql = f"SELECT w.*, rs.\"lastStartedAt\", rs.\"runCount\", rs.\"activeRunId\" FROM {fromSql}{whereClause}{orderClause}{limitClause}" + + db._ensure_connection() + with db.borrowCursor() as cursor: + cursor.execute(countSql, countValues) + totalItems = int(cursor.fetchone()["cnt"]) + + cursor.execute(dataSql, values) + rawRows = [dict(row) for row in cursor.fetchall()] + + pageSize = paginationParams.pageSize if paginationParams else max(totalItems, 1) + totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0 + + modelFields = AutoWorkflow.model_fields + for record in rawRows: + parseRecordFields(record, wfFields, "table AutoWorkflow joined listing") + for fieldName, fieldType in wfFields.items(): + if fieldType == "JSONB" and fieldName in record and record[fieldName] is None: + fieldInfo = modelFields.get(fieldName) + if fieldInfo: + fieldAnnotation = fieldInfo.annotation + if fieldAnnotation == list or ( + hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is list + ): + record[fieldName] = [] + elif fieldAnnotation == dict or ( + hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is dict + ): + record[fieldName] = {} + + return {"items": rawRows, "totalItems": totalItems, "totalPages": totalPages} diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py index 15501a0f..3a32bee2 100644 --- a/modules/system/i18nBootSync.py +++ b/modules/system/i18nBootSync.py @@ -120,7 +120,7 @@ def _registerFeatureUiLabels(): _featureModulePaths = ( "modules.features.trustee.mainTrustee", - "modules.features.graphicalEditor.mainGraphicalEditor", + "modules.workflowAutomation.mainWorkflowAutomation", "modules.features.commcoach.mainCommcoach", "modules.features.teamsbot.mainTeamsbot", "modules.features.workspace.mainWorkspace", @@ -150,7 +150,7 @@ def _registerRbacLabels(): _featureModulePaths = ( "modules.system.mainSystem", "modules.features.trustee.mainTrustee", - "modules.features.graphicalEditor.mainGraphicalEditor", + "modules.workflowAutomation.mainWorkflowAutomation", "modules.features.commcoach.mainCommcoach", "modules.features.teamsbot.mainTeamsbot", "modules.features.workspace.mainWorkspace", @@ -242,8 +242,7 @@ def _registerNodeLabels(): added += 1 try: - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES for nd in STATIC_NODE_TYPES: _reg(_extractRegistrySourceText(nd.get("label")), "node.label") _reg(_extractRegistrySourceText(nd.get("description")), "node.desc") @@ -266,8 +265,7 @@ def _registerNodeLabels(): pass try: - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG for schema in PORT_TYPE_CATALOG.values(): for field in getattr(schema, "fields", []) or []: desc = getattr(field, "description", None) diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index bbdffbbd..b85ccf0b 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -178,9 +178,9 @@ RESOURCE_OBJECTS = [ "meta": {"category": "store", "featureCode": "trustee"} }, { - "objectKey": "resource.store.graphicalEditor", + "objectKey": "resource.store.workflowAutomation", "label": t("Store: Workflow-Automation", context="UI"), - "meta": {"category": "store", "featureCode": "graphicalEditor"} + "meta": {"category": "store", "featureCode": "workflowAutomation"} }, { "objectKey": "resource.system.api.auth", diff --git a/modules/workflowAutomation/__init__.py b/modules/workflowAutomation/__init__.py new file mode 100644 index 00000000..e6472791 --- /dev/null +++ b/modules/workflowAutomation/__init__.py @@ -0,0 +1,8 @@ +""" +workflowAutomation — System component for workflow orchestration. + +Contains: +- editor/ : Graph/Flow authoring (node registry, adapters, port types) +- engine/ : Graph execution runtime (ex workflows/automation2) +- scheduler/ : Workflow scheduler + email poller +""" diff --git a/modules/workflowAutomation/editor/__init__.py b/modules/workflowAutomation/editor/__init__.py new file mode 100644 index 00000000..471ba8a5 --- /dev/null +++ b/modules/workflowAutomation/editor/__init__.py @@ -0,0 +1,5 @@ +""" +workflowAutomation.editor — Graph/Flow authoring backend. + +Node registry, port types, adapters, condition operators, entry points. +""" diff --git a/modules/features/graphicalEditor/_workflowFileSchema.py b/modules/workflowAutomation/editor/_workflowFileSchema.py similarity index 98% rename from modules/features/graphicalEditor/_workflowFileSchema.py rename to modules/workflowAutomation/editor/_workflowFileSchema.py index 2ab5dfc9..efb06aea 100644 --- a/modules/features/graphicalEditor/_workflowFileSchema.py +++ b/modules/workflowAutomation/editor/_workflowFileSchema.py @@ -1,7 +1,7 @@ # Copyright (c) 2026 Patrick Motsch # All rights reserved. """ -Workflow File Schema (Versioned Envelope) for the GraphicalEditor. +Workflow File Schema (Versioned Envelope) for WorkflowAutomation. A *workflow file* is a portable JSON representation of an ``AutoWorkflow`` that can be exchanged between mandates / instances / installations. It contains the @@ -244,7 +244,7 @@ def envelopeToWorkflowData( featureInstanceId: str, ) -> Dict[str, Any]: """Convert a validated workflow-file envelope into a dict suitable for - ``GraphicalEditorObjects.createWorkflow`` / ``updateWorkflow``. + ``WorkflowAutomationObjects.createWorkflow`` / ``updateWorkflow``. Imports are always inactive — operators must explicitly activate them. Persistence-bound fields are NEVER copied from the envelope. diff --git a/modules/features/graphicalEditor/adapterValidator.py b/modules/workflowAutomation/editor/adapterValidator.py similarity index 99% rename from modules/features/graphicalEditor/adapterValidator.py rename to modules/workflowAutomation/editor/adapterValidator.py index 08e25232..77d16a91 100644 --- a/modules/features/graphicalEditor/adapterValidator.py +++ b/modules/workflowAutomation/editor/adapterValidator.py @@ -26,7 +26,7 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Mapping -from modules.features.graphicalEditor.nodeAdapter import ( +from modules.workflowAutomation.editor.nodeAdapter import ( NodeAdapter, _adapterFromLegacyNode, _isMethodBoundNode, diff --git a/modules/features/graphicalEditor/conditionOperators.py b/modules/workflowAutomation/editor/conditionOperators.py similarity index 99% rename from modules/features/graphicalEditor/conditionOperators.py rename to modules/workflowAutomation/editor/conditionOperators.py index b375e407..3f67440f 100644 --- a/modules/features/graphicalEditor/conditionOperators.py +++ b/modules/workflowAutomation/editor/conditionOperators.py @@ -8,7 +8,7 @@ import re from datetime import datetime from typing import Any, Dict, List, Optional, Tuple -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES from modules.shared.i18nRegistry import resolveText, t logger = logging.getLogger(__name__) @@ -282,7 +282,7 @@ def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upst return "file" if not _skip_upstream: - from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths + from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths target_id = graph.get("targetNodeId") or producer_id matched_type: Optional[str] = None diff --git a/modules/features/graphicalEditor/entryPoints.py b/modules/workflowAutomation/editor/entryPoints.py similarity index 98% rename from modules/features/graphicalEditor/entryPoints.py rename to modules/workflowAutomation/editor/entryPoints.py index e70cfebb..3b4763f7 100644 --- a/modules/features/graphicalEditor/entryPoints.py +++ b/modules/workflowAutomation/editor/entryPoints.py @@ -99,7 +99,7 @@ def invocations_synced_with_graph( If the graph has no start node, only non-primary stored invocations are kept (no injected default). Document order in ``nodes`` defines which start wins. """ - from modules.workflows.automation2.graphUtils import getTriggerNodes + from modules.workflowAutomation.engine.graphUtils import getTriggerNodes g = graph if isinstance(graph, dict) else {} nodes = g.get("nodes") or [] diff --git a/modules/features/graphicalEditor/nodeAdapter.py b/modules/workflowAutomation/editor/nodeAdapter.py similarity index 100% rename from modules/features/graphicalEditor/nodeAdapter.py rename to modules/workflowAutomation/editor/nodeAdapter.py diff --git a/modules/features/graphicalEditor/nodeDefinitions/__init__.py b/modules/workflowAutomation/editor/nodeDefinitions/__init__.py similarity index 100% rename from modules/features/graphicalEditor/nodeDefinitions/__init__.py rename to modules/workflowAutomation/editor/nodeDefinitions/__init__.py diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/workflowAutomation/editor/nodeDefinitions/ai.py similarity index 99% rename from modules/features/graphicalEditor/nodeDefinitions/ai.py rename to modules/workflowAutomation/editor/nodeDefinitions/ai.py index a709f0be..37cf691f 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/ai.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/ai.py @@ -3,10 +3,10 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import ( +from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import ( CONTEXT_BUILDER_PARAM_DESCRIPTION, ) -from modules.features.graphicalEditor.nodeDefinitions.flow import ( +from modules.workflowAutomation.editor.nodeDefinitions.flow import ( CONTEXT_ENVELOPE_DATA_PICK_OPTIONS, ) diff --git a/modules/features/graphicalEditor/nodeDefinitions/clickup.py b/modules/workflowAutomation/editor/nodeDefinitions/clickup.py similarity index 99% rename from modules/features/graphicalEditor/nodeDefinitions/clickup.py rename to modules/workflowAutomation/editor/nodeDefinitions/clickup.py index 77710a64..60c60bd5 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/clickup.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/clickup.py @@ -4,7 +4,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS TASK_LIST_DATA_PICK_OPTIONS = [ { diff --git a/modules/features/graphicalEditor/nodeDefinitions/context.py b/modules/workflowAutomation/editor/nodeDefinitions/context.py similarity index 93% rename from modules/features/graphicalEditor/nodeDefinitions/context.py rename to modules/workflowAutomation/editor/nodeDefinitions/context.py index 743d92e8..839417e9 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/context.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/context.py @@ -4,7 +4,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.flow import ( +from modules.workflowAutomation.editor.nodeDefinitions.flow import ( CONTEXT_ENVELOPE_DATA_PICK_OPTIONS, CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS, ) @@ -245,7 +245,7 @@ CONTEXT_NODES = [ "description": t( "Filtert fuer die Presentation-Schicht nach typeGroup/MIME " "(gilt fuer alle Dokumenttypen analog, nicht nur PDF). " - "Passt zum Inhaltsfilter „Alles“; „Text & Tabellen“ blendet Bild-Parts in der Presentation aus." + "Passt zum Inhaltsfilter „Alles"; „Text & Tabellen" blendet Bild-Parts in der Presentation aus." ), }, { @@ -271,12 +271,7 @@ CONTEXT_NODES = [ "outputPorts": { 0: { "schema": "ActionResult", - # Override the schema-level primaryTextRef path: ``response`` is intentionally - # empty for this node; downstream nodes with ``primaryTextRef`` should resolve to - # the full presentation object under ``data``. "primaryTextRefPath": ["data"], - # Authoritative DataPicker paths (same idea as ``parameters`` for configuration). - # Frontend uses only this list — no schema expansion merge for this port. "dataPickOptions": [ { "path": ["data"], @@ -320,7 +315,6 @@ CONTEXT_NODES = [ "meta": {"icon": "mdi-file-tree-outline", "color": "#00897B", "usesAi": False}, "_method": "context", "_action": "extractContent", - # Executor behaviour flags — drives actionNodeExecutor without hardcoded type checks. "skipUnifiedPresentation": True, "clearResponse": True, "imageDocumentsFromExtractData": True, @@ -356,14 +350,10 @@ CONTEXT_NODES = [ 0: {"schema": "ActionResult", "dataPickOptions": CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS} }, "injectUpstreamPayload": True, - # Same contract as transformContext: picker paths like ``merged`` / ``first`` must match - # ``nodeOutputs`` (see actionNodeExecutor ``surfaceDataAsTopLevel``); merge payloads live in ``data``. "surfaceDataAsTopLevel": True, "meta": {"icon": "mdi-call-merge", "color": "#7B1FA2", "usesAi": False}, "_method": "context", "_action": "mergeContext", - # Image documents live on ``data.merged.imageDocumentsOnly`` (accumulated across - # iterations) rather than the top-level ``documents`` list which is always empty. "imageDocumentsFromMerged": True, }, { @@ -433,8 +423,6 @@ CONTEXT_NODES = [ "deriveFrom": "mappings", "deriveNameField": "outputField", "dataPickOptions": CONTEXT_ENVELOPE_DATA_PICK_OPTIONS, - # ActionResult is the correct normalization schema — NOT FormPayload. - # The output is a versionned ActionResult envelope built by contextEnvelope. "fromGraphResultSchema": "ActionResult", } }, diff --git a/modules/features/graphicalEditor/nodeDefinitions/contextPickerHelp.py b/modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py similarity index 78% rename from modules/features/graphicalEditor/nodeDefinitions/contextPickerHelp.py rename to modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py index 116164c1..55529951 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/contextPickerHelp.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py @@ -4,14 +4,14 @@ from modules.shared.i18nRegistry import t CONTEXT_BUILDER_PARAM_DESCRIPTION = t( - "Inhalt aus vorherigen Schritten wählen (DataRef / Daten-Picker): z. B. „response“ für Klartext, " + "Inhalt aus vorherigen Schritten wählen (DataRef / Daten-Picker): z. B. „response" für Klartext, " "Handover-Pfade für strukturiertes JSON oder Medienlisten. " "Die Auflösung erfolgt vollständig serverseitig (`resolveParameterReferences`). " - "Formular-Schritte speichern Antworten unter „payload“ — fehlt ein gewählter Pfad am Root, " - "wird derselbe Pfad automatisch unter „payload“ nachgeschlagen (Kompatibilität mit älteren " + "Formular-Schritte speichern Antworten unter „payload" — fehlt ein gewählter Pfad am Root, " + "wird derselbe Pfad automatisch unter „payload" nachgeschlagen (Kompatibilität mit älteren " "und neuen Picker-Pfaden). " "In Freitext-/Template-Feldern werden weiterhin Platzhalter `{{KnotenId.feld.b.z.}}` ersetzt " - "(gleiche Semantik inkl. optionalem Nachschlagen unter „payload“)." + "(gleiche Semantik inkl. optionalem Nachschlagen unter „payload")." ) # Kurzreferenz für Node-Beschreibungen (optional einbinden): dieselbe Auflösungslogik diff --git a/modules/features/graphicalEditor/nodeDefinitions/data.py b/modules/workflowAutomation/editor/nodeDefinitions/data.py similarity index 97% rename from modules/features/graphicalEditor/nodeDefinitions/data.py rename to modules/workflowAutomation/editor/nodeDefinitions/data.py index 118de127..c8a4a3e5 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/data.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/data.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import CONSOLIDATE_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import CONSOLIDATE_RESULT_DATA_PICK_OPTIONS AGGREGATE_RESULT_DATA_PICK_OPTIONS = [ { diff --git a/modules/features/graphicalEditor/nodeDefinitions/email.py b/modules/workflowAutomation/editor/nodeDefinitions/email.py similarity index 96% rename from modules/features/graphicalEditor/nodeDefinitions/email.py rename to modules/workflowAutomation/editor/nodeDefinitions/email.py index cc4f1474..d5c7fe8c 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/email.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/email.py @@ -3,10 +3,10 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import ( +from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import ( CONTEXT_BUILDER_PARAM_DESCRIPTION, ) -from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS EMAIL_LIST_DATA_PICK_OPTIONS = [ { diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/workflowAutomation/editor/nodeDefinitions/file.py similarity index 86% rename from modules/features/graphicalEditor/nodeDefinitions/file.py rename to modules/workflowAutomation/editor/nodeDefinitions/file.py index a10999a2..88deb5ec 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/file.py @@ -3,10 +3,10 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import ( +from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import ( CONTEXT_BUILDER_PARAM_DESCRIPTION, ) -from modules.features.graphicalEditor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS FILE_NODES = [ { @@ -14,7 +14,7 @@ FILE_NODES = [ "category": "file", "label": t("Datei erstellen"), "description": t( - "Erstellt eine Datei aus der Presentation von „Inhalt extrahieren“ " + "Erstellt eine Datei aus der Presentation von „Inhalt extrahieren" " "(``data`` oder Schleifen-``bodyResults``). Ausgabe über den Generation-Service." ), "parameters": [ @@ -37,7 +37,6 @@ FILE_NODES = [ "meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False}, "_method": "file", "_action": "create", - # Emit a debug log tracing how the ``context`` parameter was resolved. "logContextResolution": True, }, ] diff --git a/modules/features/graphicalEditor/nodeDefinitions/flow.py b/modules/workflowAutomation/editor/nodeDefinitions/flow.py similarity index 93% rename from modules/features/graphicalEditor/nodeDefinitions/flow.py rename to modules/workflowAutomation/editor/nodeDefinitions/flow.py index f1efa0ec..fe1b1f30 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/flow.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/flow.py @@ -96,7 +96,7 @@ MERGE_RESULT_DATA_PICK_OPTIONS = [ { "path": ["first"], "pickerLabel": t("Erster Zweig"), - "detail": t("Daten vom ersten verbundenen Eingang (Modus „first“)."), + "detail": t("Daten vom ersten verbundenen Eingang (Modus „first")."), "recommended": False, "type": "Any", }, @@ -243,9 +243,9 @@ FLOW_NODES = [ "category": "flow", "label": t("Schleife / Für jedes"), "description": t( - "Zwei Ausgänge: „Schleife“ verbindet den Rumpf (pro Element); optional führt der Rumpf " + "Zwei Ausgänge: „Schleife" verbindet den Rumpf (pro Element); optional führt der Rumpf " "mit einem Rücklauf-Pfeil wieder zum **gleichen Eingang** wie der vorherige Schritt (wie in n8n). " - "„Fertig“ führt genau einmal fort, wenn alle Iterationen beendet sind. " + "„Fertig" führt genau einmal fort, wenn alle Iterationen beendet sind. " "Die zu durchlaufende Liste wählen Sie wie bisher; UDM-/Strukturdaten werden automatisch sinnvoll in Elemente aufgelöst." ), "parameters": [ @@ -266,7 +266,7 @@ FLOW_NODES = [ }, "description": t( "Welche Elemente die Schleife besucht: alle, nur das erste/letzte, jedes zweite/dritte " - "oder jedes n-te (Schritt dann unter „Schrittweite“)." + "oder jedes n-te (Schritt dann unter „Schrittweite")." ), "default": "all", }, @@ -276,7 +276,7 @@ FLOW_NODES = [ "required": False, "frontendType": "number", "frontendOptions": {"min": 2, "max": 100}, - "description": t("Nur bei „jedes n-te“: Schrittweite (z. B. 5 = jedes 5. Element ab Index 0)."), + "description": t("Nur bei „jedes n-te": Schrittweite (z. B. 5 = jedes 5. Element ab Index 0)."), "default": 2, }, { @@ -333,12 +333,6 @@ FLOW_NODES = [ "default": 2, }, ], - # ``inputs: 2`` is the static minimum / default topology. ``inputCount`` is a - # frontend hint: the editor adds/removes input ports dynamically when the user - # changes the value. ``FlowExecutor._merge`` collects whatever ports exist in - # ``inputSources`` at runtime, so extra ports (3–5) work without further changes - # to this definition. ``inputPorts`` below only type-declares the two minimum - # ports; additional ports inherit the same ``_FLOW_INPUT_SCHEMAS`` accepts list. "inputs": 2, "outputs": 1, "inputPorts": { diff --git a/modules/features/graphicalEditor/nodeDefinitions/input.py b/modules/workflowAutomation/editor/nodeDefinitions/input.py similarity index 98% rename from modules/features/graphicalEditor/nodeDefinitions/input.py rename to modules/workflowAutomation/editor/nodeDefinitions/input.py index 5bf84e74..5c152fdb 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/input.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/input.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS BOOL_RESULT_DATA_PICK_OPTIONS = [ { diff --git a/modules/features/graphicalEditor/nodeDefinitions/redmine.py b/modules/workflowAutomation/editor/nodeDefinitions/redmine.py similarity index 98% rename from modules/features/graphicalEditor/nodeDefinitions/redmine.py rename to modules/workflowAutomation/editor/nodeDefinitions/redmine.py index 675fe957..f20f2901 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/redmine.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/redmine.py @@ -4,7 +4,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS # Typed FeatureInstance binding (replaces legacy `string, hidden`). # - type FeatureInstanceRef[redmine] is filtered by the DataPicker. diff --git a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py b/modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py similarity index 99% rename from modules/features/graphicalEditor/nodeDefinitions/sharepoint.py rename to modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py index 2a1a1a32..db48d8db 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import ( +from modules.workflowAutomation.editor.nodeDefinitions.ai import ( ACTION_RESULT_DATA_PICK_OPTIONS, DOCUMENT_LIST_DATA_PICK_OPTIONS, ) diff --git a/modules/features/graphicalEditor/nodeDefinitions/triggers.py b/modules/workflowAutomation/editor/nodeDefinitions/triggers.py similarity index 96% rename from modules/features/graphicalEditor/nodeDefinitions/triggers.py rename to modules/workflowAutomation/editor/nodeDefinitions/triggers.py index 074125e2..0ae34ff2 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/triggers.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/triggers.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS TRIGGER_NODES = [ { diff --git a/modules/features/graphicalEditor/nodeDefinitions/trustee.py b/modules/workflowAutomation/editor/nodeDefinitions/trustee.py similarity index 93% rename from modules/features/graphicalEditor/nodeDefinitions/trustee.py rename to modules/workflowAutomation/editor/nodeDefinitions/trustee.py index d6a82e4b..a8c390a8 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/trustee.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/trustee.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS # Typed FeatureInstance binding (replaces legacy `string, hidden`). # - type uses the discriminator notation `FeatureInstanceRef[]` so the @@ -61,9 +61,6 @@ TRUSTEE_NODES = [ "inputs": 1, "outputs": 1, "inputPorts": {0: {"accepts": ["DocumentList", "Transit", "AiResult", "LoopItem", "ActionResult"]}}, - # Runtime returns ActionResult.isSuccess(documents=[...]) — see - # actions/extractFromFiles.py. Declaring DocumentList here was adapter - # drift and broke the DataPicker for downstream nodes. "outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}}, "meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50", "usesAi": True}, "_method": "trustee", @@ -75,9 +72,6 @@ TRUSTEE_NODES = [ "label": t("Dokumente verarbeiten"), "description": t("TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen."), "parameters": [ - # Type matches what producers actually emit: ActionResult.documents - # is List[ActionDocument] (see datamodelChat.ActionResult). The - # DataPicker uses this string to filter compatible upstream paths. {"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef", "description": t("Dokumente aus vorherigen Schritten"), "graphInherit": {"port": 0, "kind": "documentListWire"}}, diff --git a/modules/features/graphicalEditor/nodeRegistry.py b/modules/workflowAutomation/editor/nodeRegistry.py similarity index 92% rename from modules/features/graphicalEditor/nodeRegistry.py rename to modules/workflowAutomation/editor/nodeRegistry.py index 0b0c09fd..bbddd9f0 100644 --- a/modules/features/graphicalEditor/nodeRegistry.py +++ b/modules/workflowAutomation/editor/nodeRegistry.py @@ -1,18 +1,18 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Node Type Registry for graphicalEditor - static node definitions (start, input, flow, data, ai, email, …). +Node Type Registry for WorkflowAutomation editor - static node definitions (start, input, flow, data, ai, email, …). Nodes are defined first; IO/method actions are used at execution time. """ import logging from typing import Dict, List, Any, Optional -from modules.features.graphicalEditor.conditionOperators import localize_operator_catalog -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES -from modules.features.graphicalEditor.nodeAdapter import bindsActionFromLegacy -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES +from modules.workflowAutomation.editor.conditionOperators import localize_operator_catalog +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.nodeDefinitions.input import FORM_FIELD_TYPES +from modules.workflowAutomation.editor.nodeAdapter import bindsActionFromLegacy +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText logger = logging.getLogger(__name__) @@ -178,7 +178,7 @@ def validateAdaptersAgainstMethods(methodInstances: Optional[Dict[str, Any]] = N Pass `methodInstances` directly for testability; defaults to importing the live registry from `methodDiscovery.methods`. """ - from modules.features.graphicalEditor.adapterValidator import ( + from modules.workflowAutomation.editor.adapterValidator import ( _buildActionsRegistryFromMethods, _formatAdapterReport, _validateAllAdapters, diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/workflowAutomation/editor/portTypes.py similarity index 99% rename from modules/features/graphicalEditor/portTypes.py rename to modules/workflowAutomation/editor/portTypes.py index a7eb0f3f..6246896e 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/workflowAutomation/editor/portTypes.py @@ -418,7 +418,7 @@ def deriveFormPayloadSchemaFromParam( - Group-fields: ``type == "group"`` recursed via ``fields``. - List[str]: each string is taken as a leaf path key (used for ``filterContext.keys``). """ - from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES + from modules.workflowAutomation.editor.nodeDefinitions.input import FORM_FIELD_TYPES _FORM_TYPE_TO_PORT: Dict[str, str] = {f["id"]: f["portType"] for f in FORM_FIELD_TYPES} fields_param = (node.get("parameters") or {}).get(param_key) diff --git a/modules/features/graphicalEditor/switchOutput.py b/modules/workflowAutomation/editor/switchOutput.py similarity index 99% rename from modules/features/graphicalEditor/switchOutput.py rename to modules/workflowAutomation/editor/switchOutput.py index be469ead..e7cc830b 100644 --- a/modules/features/graphicalEditor/switchOutput.py +++ b/modules/workflowAutomation/editor/switchOutput.py @@ -7,7 +7,7 @@ import copy import re from typing import Any, Dict, List, Optional -from modules.features.graphicalEditor.portTypes import unwrapTransit +from modules.workflowAutomation.editor.portTypes import unwrapTransit _CONTEXT_FILTER_OPERATORS = frozenset({"contains_content"}) _BLOB_IMAGE_CHUNK_RE = re.compile(r"^\[image(?:\:([^\]]+))?\]$") diff --git a/modules/features/graphicalEditor/upstreamPathsService.py b/modules/workflowAutomation/editor/upstreamPathsService.py similarity index 95% rename from modules/features/graphicalEditor/upstreamPathsService.py rename to modules/workflowAutomation/editor/upstreamPathsService.py index ade9524a..f3d2a6ab 100644 --- a/modules/features/graphicalEditor/upstreamPathsService.py +++ b/modules/workflowAutomation/editor/upstreamPathsService.py @@ -4,10 +4,10 @@ from __future__ import annotations from typing import Any, Dict, List, Set -from modules.features.graphicalEditor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema -from modules.workflows.automation2.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds +from modules.workflowAutomation.editor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema +from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds _NODE_BY_TYPE = {n["id"]: n for n in STATIC_NODE_TYPES} diff --git a/modules/workflowAutomation/engine/__init__.py b/modules/workflowAutomation/engine/__init__.py new file mode 100644 index 00000000..0656ab39 --- /dev/null +++ b/modules/workflowAutomation/engine/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2025 Patrick Motsch +# automation2 - n8n-style graph execution engine. diff --git a/modules/workflows/automation2/clickupTaskUpdateMerge.py b/modules/workflowAutomation/engine/clickupTaskUpdateMerge.py similarity index 100% rename from modules/workflows/automation2/clickupTaskUpdateMerge.py rename to modules/workflowAutomation/engine/clickupTaskUpdateMerge.py diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py similarity index 98% rename from modules/workflows/automation2/executionEngine.py rename to modules/workflowAutomation/engine/executionEngine.py index b6313342..cbe572da 100644 --- a/modules/workflows/automation2/executionEngine.py +++ b/modules/workflowAutomation/engine/executionEngine.py @@ -9,7 +9,7 @@ import uuid from datetime import datetime, timezone from typing import Dict, Any, List, Set, Optional -from modules.workflows.automation2.graphUtils import ( +from modules.workflowAutomation.engine.graphUtils import ( parseGraph, buildConnectionMap, validateGraph, @@ -20,7 +20,7 @@ from modules.workflows.automation2.graphUtils import ( getLoopPrimaryInputSource, ) -from modules.workflows.automation2.executors import ( +from modules.workflowAutomation.engine.executors import ( TriggerExecutor, FlowExecutor, ActionNodeExecutor, @@ -29,15 +29,15 @@ from modules.workflows.automation2.executors import ( PauseForHumanTaskError, PauseForEmailWaitError, ) -from modules.features.graphicalEditor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError -from modules.workflows.automation2.graphicalEditorRunFileLogger import ( - GraphicalEditorRunFileLogger, +from modules.workflowAutomation.engine.runFileLogger import ( + RunFileLogger, graphical_editor_run_file_logging_enabled, merge_run_context_with_ge_log_prefix, ) -from modules.workflows.automation2.runEnvelope import normalize_run_envelope +from modules.workflowAutomation.engine.runEnvelope import normalize_run_envelope logger = logging.getLogger(__name__) @@ -269,7 +269,7 @@ def _createStepLog(iface, runId: str, nodeId: str, nodeType: str, status: str = if not iface or not runId: return None try: - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoStepLog + from modules.datamodels.datamodelWorkflowAutomation import AutoStepLog stepId = str(uuid.uuid4()) startedAt = time.time() iface.db.recordCreate(AutoStepLog, { @@ -298,7 +298,7 @@ def _updateStepLog(iface, stepId: str, status: str, output: Dict = None, error: if not iface or not stepId: return try: - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoStepLog + from modules.datamodels.datamodelWorkflowAutomation import AutoStepLog completedAt = time.time() updates: Dict[str, Any] = { "status": status, @@ -333,7 +333,7 @@ def _ge_iso_timestamp() -> str: async def _ge_log_node_finished( - file_logger: Optional[GraphicalEditorRunFileLogger], + file_logger: Optional[RunFileLogger], *, run_id: Optional[str], node_outputs: Dict[str, Any], @@ -511,7 +511,7 @@ async def _run_post_loop_done_nodes( automation2_interface: Optional[Any], runId: Optional[str], processed_in_loop: Set[str], - ge_file_logger: Optional[GraphicalEditorRunFileLogger] = None, + ge_file_logger: Optional[RunFileLogger] = None, ) -> Optional[Dict[str, Any]]: """After all loop iterations: merge upstream into loop output and run the Done (output 1) branch once.""" _prim_in = getLoopPrimaryInputSource(loop_node_id, connectionMap, body_ids) @@ -705,13 +705,13 @@ async def executeGraph( ) from modules.workflows.processing.shared.methodDiscovery import discoverMethods discoverMethods(services) - from modules.workflows.automation2.pickNotPushMigration import ( + from modules.workflowAutomation.engine.pickNotPushMigration import ( materializeConnectionRefs, materializePrimaryTextHandover, materializeRecommendedDataPickRef, normalizeFileCreatePresentationRefs, ) - from modules.workflows.automation2.featureInstanceRefMigration import ( + from modules.workflowAutomation.engine.featureInstanceRefMigration import ( materializeFeatureInstanceRefs, ) @@ -767,7 +767,7 @@ async def executeGraph( except Exception as valErr: logger.warning("executeGraph resume: schema validation failed for %s: %s", startAfterNodeId, valErr) - ge_file_logger: Optional[GraphicalEditorRunFileLogger] = None + ge_file_logger: Optional[RunFileLogger] = None nodeOutputs: Dict[str, Any] = dict(initialNodeOutputs or {}) if not runId and automation2_interface and workflowId and not is_resume: run_context = { @@ -806,7 +806,7 @@ async def executeGraph( runId = run.get("id") if run else None logger.info("executeGraph created run %s label=%s", runId, run_label) if runId and graphical_editor_run_file_logging_enabled(): - ge_file_logger = GraphicalEditorRunFileLogger.bootstrap_new_run( + ge_file_logger = RunFileLogger.bootstrap_new_run( automation2_interface, runId, run_context, @@ -847,7 +847,7 @@ async def executeGraph( and runId and ge_file_logger is None ): - ge_file_logger = GraphicalEditorRunFileLogger.ensure_attached( + ge_file_logger = RunFileLogger.ensure_attached( automation2_interface, runId, ) @@ -1542,7 +1542,7 @@ async def executeGraph( logger.info("executeGraph paused for email wait (run %s, node %s)", e.runId, e.nodeId) try: from modules.interfaces.interfaceDbApp import getRootInterface - from modules.features.graphicalEditor.emailPoller import ensureRunning + from modules.workflowAutomation.scheduler.emailPoller import ensureRunning root = getRootInterface() event_user = root.getUserByUsername("event") if root else None if event_user: @@ -1612,7 +1612,7 @@ async def executeGraph( ) if _wfObj else {} _shouldNotify = _wfDict.get("notifyOnFailure", True) if _wfDict else True if _shouldNotify: - from modules.workflows.scheduler.mainScheduler import notifyRunFailed + from modules.workflowAutomation.scheduler.mainScheduler import notifyRunFailed notifyRunFailed( workflowId or "", runId or "", str(e), mandateId=mandateId, diff --git a/modules/workflowAutomation/engine/executors/__init__.py b/modules/workflowAutomation/engine/executors/__init__.py new file mode 100644 index 00000000..4d2180c3 --- /dev/null +++ b/modules/workflowAutomation/engine/executors/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2025 Patrick Motsch +# Executors for automation2 node types. + +from .triggerExecutor import TriggerExecutor +from .flowExecutor import FlowExecutor +from .actionNodeExecutor import ActionNodeExecutor +from .inputExecutor import InputExecutor, PauseForHumanTaskError, PauseForEmailWaitError +from .dataExecutor import DataExecutor + +__all__ = [ + "TriggerExecutor", + "FlowExecutor", + "ActionNodeExecutor", + "InputExecutor", + "DataExecutor", + "PauseForHumanTaskError", + "PauseForEmailWaitError", +] diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py similarity index 97% rename from modules/workflows/automation2/executors/actionNodeExecutor.py rename to modules/workflowAutomation/engine/executors/actionNodeExecutor.py index ee1101e5..41c88a5d 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py @@ -13,12 +13,12 @@ import re import time from typing import Any, Dict, Optional -from modules.features.graphicalEditor.portTypes import ( +from modules.workflowAutomation.editor.portTypes import ( _normalizeError, normalizeToSchema, ) from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError -from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError +from modules.workflowAutomation.engine.executors.inputExecutor import PauseForHumanTaskError from modules.workflows.methods.methodContext.actions.extractContent import ( PRESENTATION_KIND, build_presentation_envelope_from_plain_text, @@ -181,7 +181,7 @@ def _isUserConnectionId(val: Any) -> bool: def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]: """Get node definition by type id.""" - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES for node in STATIC_NODE_TYPES: if node.get("id") == nodeType: return node @@ -304,7 +304,7 @@ def _buildConnectionRefDict(connRef: str, chatService, services) -> Optional[Dic def _schemaCarriesConnectionProvenance(outputSchema: str) -> bool: """True iff the port schema declares ``carriesConnectionProvenance`` in the catalog.""" - from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG schema = PORT_TYPE_CATALOG.get(outputSchema) return bool(getattr(schema, "carriesConnectionProvenance", False)) @@ -388,7 +388,7 @@ def _mapper_emailDraftContextFromSubjectBody(params: Dict, **_) -> None: def _mapper_clickupTaskUpdateMerge(params: Dict, **_) -> None: - from modules.workflows.automation2.clickupTaskUpdateMerge import merge_clickup_task_update_entries + from modules.workflowAutomation.engine.clickupTaskUpdateMerge import merge_clickup_task_update_entries merge_clickup_task_update_entries(params) @@ -430,7 +430,7 @@ def _resolveUpstreamPayload(nodeId: str, context: Dict[str, Any]) -> Any: the first ``connectionMap`` entry so ``injectUpstreamPayload`` (e.g. ``context.mergeContext`` after ``flow.loop``) still receives data. """ - from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port + from modules.workflowAutomation.editor.switchOutput import unwrap_transit_for_port nodeOutputs = context.get("nodeOutputs") or {} connectionMap = context.get("connectionMap") or {} @@ -456,9 +456,9 @@ def _resolveUpstreamPayload(nodeId: str, context: Dict[str, Any]) -> Any: return unwrap_transit_for_port(upstream, src_out) -def _resolveBranchInputs(nodeId: str, context: Dict[str, Any]) -> Dict[int, Any]: + def _resolveBranchInputs(nodeId: str, context: Dict[str, Any]) -> Dict[int, Any]: """Return ``Dict[port_index → unwrapped upstream output]`` for every wired input port.""" - from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port + from modules.workflowAutomation.editor.switchOutput import unwrap_transit_for_port src_map = (context.get("inputSources") or {}).get(nodeId) or {} nodeOutputs = context.get("nodeOutputs") or {} out: Dict[int, Any] = {} @@ -484,8 +484,8 @@ class ActionNodeExecutor: node: Dict[str, Any], context: Dict[str, Any], ) -> Any: - from modules.features.graphicalEditor.nodeRegistry import getNodeTypeToMethodAction - from modules.workflows.automation2.graphUtils import ( + from modules.workflowAutomation.editor.nodeRegistry import getNodeTypeToMethodAction + from modules.workflowAutomation.engine.graphUtils import ( document_list_param_is_empty, extract_wired_document_list, resolveParameterReferences, @@ -569,7 +569,7 @@ class ActionNodeExecutor: workflowId = context.get("workflowId") connRef = resolvedParams.get("connectionReference") if runId and workflowId and connRef: - from modules.workflows.automation2.executors import PauseForEmailWaitError + from modules.workflowAutomation.engine.executors import PauseForEmailWaitError waitConfig = { "connectionReference": connRef, "folder": resolvedParams.get("folder", "Inbox"), diff --git a/modules/workflows/automation2/executors/dataExecutor.py b/modules/workflowAutomation/engine/executors/dataExecutor.py similarity index 99% rename from modules/workflows/automation2/executors/dataExecutor.py rename to modules/workflowAutomation/engine/executors/dataExecutor.py index ef205590..3429e650 100644 --- a/modules/workflows/automation2/executors/dataExecutor.py +++ b/modules/workflowAutomation/engine/executors/dataExecutor.py @@ -4,7 +4,7 @@ import logging from typing import Any, Dict -from modules.features.graphicalEditor.portTypes import unwrapTransit, wrapTransit +from modules.workflowAutomation.editor.portTypes import unwrapTransit, wrapTransit logger = logging.getLogger(__name__) diff --git a/modules/workflows/automation2/executors/flowExecutor.py b/modules/workflowAutomation/engine/executors/flowExecutor.py similarity index 96% rename from modules/workflows/automation2/executors/flowExecutor.py rename to modules/workflowAutomation/engine/executors/flowExecutor.py index 3da89a87..f107a580 100644 --- a/modules/workflows/automation2/executors/flowExecutor.py +++ b/modules/workflowAutomation/engine/executors/flowExecutor.py @@ -5,8 +5,8 @@ import logging from datetime import datetime from typing import Any, Dict, List, Optional -from modules.features.graphicalEditor.conditionOperators import apply_condition_operator, resolve_value_kind -from modules.features.graphicalEditor.portTypes import wrapTransit, unwrapTransit +from modules.workflowAutomation.editor.conditionOperators import apply_condition_operator, resolve_value_kind +from modules.workflowAutomation.editor.portTypes import wrapTransit, unwrapTransit logger = logging.getLogger(__name__) @@ -90,7 +90,7 @@ class FlowExecutor: return False if isinstance(condParam, dict) and condParam.get("type") == "condition": return self._evalStructuredCondition(condParam, nodeOutputs, item_param=item_param, node=node) - from modules.workflows.automation2.graphUtils import resolveParameterReferences + from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences resolved = resolveParameterReferences(condParam, nodeOutputs) return self._evalCondition(resolved) @@ -121,7 +121,7 @@ class FlowExecutor: node: Optional[Dict] = None, ) -> bool: """Evaluate structured {operator, value} with Item dataRef (legacy: condition.ref).""" - from modules.workflows.automation2.graphUtils import resolveParameterReferences + from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences left_ref = item_param if left_ref is None or (isinstance(left_ref, dict) and not left_ref): @@ -208,8 +208,8 @@ class FlowExecutor: async def _switch(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any: params = node.get("parameters") or {} valueExpr = params.get("value", "") - from modules.workflows.automation2.graphUtils import resolveParameterReferences - from modules.features.graphicalEditor.switchOutput import ( + from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences + from modules.workflowAutomation.editor.switchOutput import ( build_switch_combined_output, build_switch_default_payload, ) @@ -258,7 +258,7 @@ class FlowExecutor: async def _loop(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any: params = node.get("parameters") or {} itemsPath = params.get("items", "[]") - from modules.workflows.automation2.graphUtils import resolveParameterReferences + from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences raw = resolveParameterReferences( itemsPath, diff --git a/modules/workflows/automation2/executors/inputExecutor.py b/modules/workflowAutomation/engine/executors/inputExecutor.py similarity index 95% rename from modules/workflows/automation2/executors/inputExecutor.py rename to modules/workflowAutomation/engine/executors/inputExecutor.py index aaf31ff1..39efcfe6 100644 --- a/modules/workflows/automation2/executors/inputExecutor.py +++ b/modules/workflowAutomation/engine/executors/inputExecutor.py @@ -47,7 +47,7 @@ class InputExecutor: ) taskId = task.get("id") - from modules.workflows.automation2.graphicalEditorRunFileLogger import merge_persisted_run_context + from modules.workflowAutomation.engine.runFileLogger import merge_persisted_run_context _pause_ctx = merge_persisted_run_context( self.automation2, diff --git a/modules/workflows/automation2/executors/ioExecutor.py b/modules/workflowAutomation/engine/executors/ioExecutor.py similarity index 95% rename from modules/workflows/automation2/executors/ioExecutor.py rename to modules/workflowAutomation/engine/executors/ioExecutor.py index 14bc8f91..ae527adf 100644 --- a/modules/workflows/automation2/executors/ioExecutor.py +++ b/modules/workflowAutomation/engine/executors/ioExecutor.py @@ -37,7 +37,7 @@ class IOExecutor: nodeOutputs = context.get("nodeOutputs", {}) params = dict(node.get("parameters") or {}) - from modules.workflows.automation2.graphUtils import extract_wired_document_list, resolveParameterReferences + from modules.workflowAutomation.engine.graphUtils import extract_wired_document_list, resolveParameterReferences resolvedParams = resolveParameterReferences(params, nodeOutputs) logger.info("IOExecutor node %s resolvedParams keys=%s", nodeId, list(resolvedParams.keys())) diff --git a/modules/workflows/automation2/executors/triggerExecutor.py b/modules/workflowAutomation/engine/executors/triggerExecutor.py similarity index 94% rename from modules/workflows/automation2/executors/triggerExecutor.py rename to modules/workflowAutomation/engine/executors/triggerExecutor.py index cd2d118e..35b46237 100644 --- a/modules/workflows/automation2/executors/triggerExecutor.py +++ b/modules/workflowAutomation/engine/executors/triggerExecutor.py @@ -4,7 +4,7 @@ import logging from typing import Any, Dict -from modules.workflows.automation2.runEnvelope import normalize_run_envelope +from modules.workflowAutomation.engine.runEnvelope import normalize_run_envelope logger = logging.getLogger(__name__) diff --git a/modules/workflows/automation2/featureInstanceRefMigration.py b/modules/workflowAutomation/engine/featureInstanceRefMigration.py similarity index 100% rename from modules/workflows/automation2/featureInstanceRefMigration.py rename to modules/workflowAutomation/engine/featureInstanceRefMigration.py diff --git a/modules/workflows/automation2/graphUtils.py b/modules/workflowAutomation/engine/graphUtils.py similarity index 97% rename from modules/workflows/automation2/graphUtils.py rename to modules/workflowAutomation/engine/graphUtils.py index 9130f023..946faafa 100644 --- a/modules/workflows/automation2/graphUtils.py +++ b/modules/workflowAutomation/engine/graphUtils.py @@ -91,7 +91,7 @@ def getLoopPrimaryInputSource( ) -> Optional[Tuple[str, int]]: """Pick the inbound edge for ``flow.loop`` when several wires hit the same input (0). - The Schleifen-Rücklauf vom Rumpf und der „normale“ Vorgänger enden auf demselben Port; + The Schleifen-Rücklauf vom Rumpf und der „normale" Vorgänger enden auf demselben Port; für die Datenzusammenführung (Fertig-Ausgang, Logs) zählt der Vorgänger **außerhalb** des Rumpfes. """ incoming = connectionMap.get(loop_node_id, []) @@ -209,7 +209,7 @@ def parse_graph_defined_schema(node: Dict[str, Any], parameter_key: str) -> Opti Build a JSON-serializable port schema dict from graph parameters (e.g. form ``fields``). Used by tooling and future API surfaces; mirrors ``parse_graph_defined_output_schema`` logic. """ - from modules.features.graphicalEditor.portTypes import deriveFormPayloadSchemaFromParam + from modules.workflowAutomation.editor.portTypes import deriveFormPayloadSchemaFromParam sch = deriveFormPayloadSchemaFromParam(node, parameter_key) if sch is None: @@ -227,8 +227,8 @@ def _checkPortCompatibility( """ Hard typed-port check: incompatible connections become validation errors. """ - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES - from modules.features.graphicalEditor.portTypes import resolve_output_schema_name + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.portTypes import resolve_output_schema_name nodeDefMap = {n["id"]: n for n in STATIC_NODE_TYPES} nodeById = {n["id"]: n for n in nodes if n.get("id")} @@ -443,14 +443,14 @@ def resolveParameterReferences( if consumer_node_id and input_sources: wired = (input_sources.get(consumer_node_id) or {}).get(0) if wired and wired[0] == node_id: - from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port + from modules.workflowAutomation.editor.switchOutput import unwrap_transit_for_port data = unwrap_transit_for_port(data, wired[1]) elif isinstance(data, dict) and data.get("_transit"): data = data.get("data", data) plist = list(path) resolved = _get_by_path(data, plist) if resolved is None: - from modules.workflows.automation2.pickNotPushMigration import ( + from modules.workflowAutomation.engine.pickNotPushMigration import ( remap_stale_presentation_ref_path, ) alt_path = remap_stale_presentation_ref_path(plist) @@ -481,7 +481,7 @@ def resolveParameterReferences( ) if value.get("type") == "system": variable = value.get("variable", "") - from modules.features.graphicalEditor.portTypes import resolveSystemVariable + from modules.workflowAutomation.editor.portTypes import resolveSystemVariable return resolveSystemVariable(variable, nodeOutputs.get("_context", {})) return { k: resolveParameterReferences( @@ -576,7 +576,7 @@ def extract_wired_document_list(inp: Any) -> Optional[Dict[str, Any]]: """ if inp is None: return None - from modules.features.graphicalEditor.portTypes import ( + from modules.workflowAutomation.editor.portTypes import ( unwrapTransit, _coerce_document_list_upload_fields, _file_record_to_document, diff --git a/modules/workflows/automation2/pickNotPushMigration.py b/modules/workflowAutomation/engine/pickNotPushMigration.py similarity index 97% rename from modules/workflows/automation2/pickNotPushMigration.py rename to modules/workflowAutomation/engine/pickNotPushMigration.py index a40e6c33..1b3d9249 100644 --- a/modules/workflows/automation2/pickNotPushMigration.py +++ b/modules/workflowAutomation/engine/pickNotPushMigration.py @@ -16,12 +16,12 @@ import copy import logging from typing import Any, Dict, List, Optional -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.portTypes import ( +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import ( PRIMARY_TEXT_HANDOVER_REF_PATH, resolve_output_schema_name, ) -from modules.workflows.automation2.graphUtils import buildConnectionMap, getInputSources +from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getInputSources logger = logging.getLogger(__name__) diff --git a/modules/workflows/automation2/runEnvelope.py b/modules/workflowAutomation/engine/runEnvelope.py similarity index 100% rename from modules/workflows/automation2/runEnvelope.py rename to modules/workflowAutomation/engine/runEnvelope.py diff --git a/modules/workflows/automation2/graphicalEditorRunFileLogger.py b/modules/workflowAutomation/engine/runFileLogger.py similarity index 97% rename from modules/workflows/automation2/graphicalEditorRunFileLogger.py rename to modules/workflowAutomation/engine/runFileLogger.py index ac28ddb1..07600317 100644 --- a/modules/workflows/automation2/graphicalEditorRunFileLogger.py +++ b/modules/workflowAutomation/engine/runFileLogger.py @@ -53,7 +53,7 @@ def merge_persisted_run_context( return {**prev, **(replacement or {})} -class GraphicalEditorRunFileLogger: +class RunFileLogger: """Append-only NDJSON log for one run folder under ``resolve_app_log_dir()``.""" __slots__ = ("_exec_path", "_ctx_path", "_lock", "_run_id") @@ -80,7 +80,7 @@ class GraphicalEditorRunFileLogger: return "/".join((RUN_FILE_LOG_RELATIVE_ROOT, subdir_name)) @classmethod - def bootstrap_new_run(cls, automation2_interface: Any, run_id: str, run_context: Dict[str, Any]) -> GraphicalEditorRunFileLogger | None: + def bootstrap_new_run(cls, automation2_interface: Any, run_id: str, run_context: Dict[str, Any]) -> RunFileLogger | None: """Create filesystem folder + persist CONTEXT_KEY via ``updateRun``.""" if not graphical_editor_run_file_logging_enabled(): return None @@ -107,7 +107,7 @@ class GraphicalEditorRunFileLogger: return cls(run_id, absolute) @classmethod - def open_from_run_record(cls, automation2_interface: Any, run_id: str) -> GraphicalEditorRunFileLogger | None: + def open_from_run_record(cls, automation2_interface: Any, run_id: str) -> RunFileLogger | None: """Open logger for an existing run using CONTEXT_KEY from DB.""" if not graphical_editor_run_file_logging_enabled(): return None @@ -154,7 +154,7 @@ class GraphicalEditorRunFileLogger: return cand if os.path.isdir(cand) else None @classmethod - def ensure_attached(cls, automation2_interface: Any, run_id: str) -> GraphicalEditorRunFileLogger | None: + def ensure_attached(cls, automation2_interface: Any, run_id: str) -> RunFileLogger | None: """Open logger from DB, or reattach an on-disk folder for *run_id*, or create a new one.""" opened = cls.open_from_run_record(automation2_interface, run_id) if opened is not None: diff --git a/modules/workflows/automation2/scheduleCron.py b/modules/workflowAutomation/engine/scheduleCron.py similarity index 100% rename from modules/workflows/automation2/scheduleCron.py rename to modules/workflowAutomation/engine/scheduleCron.py diff --git a/modules/workflows/automation2/udmUpstreamShapes.py b/modules/workflowAutomation/engine/udmUpstreamShapes.py similarity index 100% rename from modules/workflows/automation2/udmUpstreamShapes.py rename to modules/workflowAutomation/engine/udmUpstreamShapes.py diff --git a/modules/workflows/automation2/workflowArtifactVisibility.py b/modules/workflowAutomation/engine/workflowArtifactVisibility.py similarity index 100% rename from modules/workflows/automation2/workflowArtifactVisibility.py rename to modules/workflowAutomation/engine/workflowArtifactVisibility.py diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/workflowAutomation/mainWorkflowAutomation.py similarity index 72% rename from modules/features/graphicalEditor/mainGraphicalEditor.py rename to modules/workflowAutomation/mainWorkflowAutomation.py index f88ccfdc..754d77b5 100644 --- a/modules/features/graphicalEditor/mainGraphicalEditor.py +++ b/modules/workflowAutomation/mainWorkflowAutomation.py @@ -1,8 +1,10 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -GraphicalEditor Feature - n8n-style flow automation. -Minimal bootstrap for feature instance creation. Build from here. +WorkflowAutomation System Component — n8n-style flow automation. + +System-level orchestration infrastructure (not a feature). +Provides lifecycle hooks, service hub, and system templates. """ import json @@ -14,7 +16,7 @@ from modules.shared.i18nRegistry import t logger = logging.getLogger(__name__) -FEATURE_CODE = "graphicalEditor" +COMPONENT_CODE = "workflowAutomation" REQUIRED_SERVICES = [ {"serviceKey": "chat", "meta": {"usage": "Interfaces, RBAC"}}, @@ -25,41 +27,21 @@ REQUIRED_SERVICES = [ {"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}}, {"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}}, ] -FEATURE_LABEL = t("Grafischer Editor", context="UI") - -RESOURCE_OBJECTS = [ - { - "objectKey": "resource.feature.graphicalEditor.dashboard", - "label": t("Dashboard aufrufen", context="UI"), - "meta": {"endpoint": "/api/workflows/{instanceId}/info", "method": "GET"} - }, - { - "objectKey": "resource.feature.graphicalEditor.node-types", - "label": t("Node-Typen abrufen", context="UI"), - "meta": {"endpoint": "/api/workflows/{instanceId}/node-types", "method": "GET"} - }, - { - "objectKey": "resource.feature.graphicalEditor.execute", - "label": t("Workflow ausführen", context="UI"), - "meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"} - }, -] - -def getRequiredServiceKeys() -> List[str]: - """Return list of service keys this feature requires.""" +def _getRequiredServiceKeys() -> List[str]: + """Return list of service keys this component requires.""" return [s["serviceKey"] for s in REQUIRED_SERVICES] -def getGraphicalEditorServices( +def _getWorkflowAutomationServices( user, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None, workflow=None, -) -> "_GraphicalEditorServiceHub": +) -> "_WorkflowAutomationServiceHub": """ - Get a service hub for graphicalEditor using the service center. + Get a service hub for WorkflowAutomation using the service center. Used for methodDiscovery (I/O nodes) and execution (ActionExecutor). """ from modules.serviceCenter import getService @@ -70,7 +52,7 @@ def getGraphicalEditorServices( _workflow = type( "_Placeholder", (), - {"featureCode": FEATURE_CODE, "id": f"transient-{uuid.uuid4().hex[:12]}", "workflowMode": None, "messages": []}, + {"featureCode": COMPONENT_CODE, "id": f"transient-{uuid.uuid4().hex[:12]}", "workflowMode": None, "messages": []}, )() ctx = ServiceCenterContext( @@ -80,13 +62,13 @@ def getGraphicalEditorServices( workflow=_workflow, ) - hub = _GraphicalEditorServiceHub() + hub = _WorkflowAutomationServiceHub() hub.user = user hub.mandateId = mandateId hub.featureInstanceId = featureInstanceId hub._service_context = ctx hub.workflow = _workflow - hub.featureCode = FEATURE_CODE + hub.featureCode = COMPONENT_CODE for spec in REQUIRED_SERVICES: key = spec["serviceKey"] @@ -94,7 +76,7 @@ def getGraphicalEditorServices( svc = getService(key, ctx) setattr(hub, key, svc) except Exception as e: - logger.warning(f"Could not resolve service '{key}' for graphicalEditor: {e}") + logger.warning(f"Could not resolve service '{key}' for workflowAutomation: {e}") setattr(hub, key, None) if hub.chat: @@ -106,19 +88,17 @@ def getGraphicalEditorServices( return hub -# Backward-compatible alias used by workflows/automation2/ execution engine -getAutomation2Services = getGraphicalEditorServices -class _GraphicalEditorServiceHub: - """Lightweight hub for graphicalEditor (methodDiscovery, execution).""" +class _WorkflowAutomationServiceHub: + """Lightweight hub for WorkflowAutomation (methodDiscovery, execution).""" user = None mandateId = None featureInstanceId = None _service_context = None workflow = None - featureCode = FEATURE_CODE + featureCode = COMPONENT_CODE interfaceDbApp = None interfaceDbComponent = None interfaceDbChat = None @@ -132,14 +112,12 @@ class _GraphicalEditorServiceHub: generation = None - - # --------------------------------------------------------------------------- -# Feature Lifecycle Hooks (called dynamically by core via loadFeatureMainModules) +# Lifecycle Hooks # --------------------------------------------------------------------------- def onMandateDelete(mandateId: str, instances: list) -> None: - """Cascade-delete all AutoWorkflow data in the Greenfield DB for this mandate.""" + """Cascade-delete all AutoWorkflow data for this mandate.""" from modules.datamodels.datamodelWorkflowAutomation import ( GRAPHICAL_EDITOR_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, ) @@ -147,7 +125,7 @@ def onMandateDelete(mandateId: str, instances: list) -> None: from modules.shared.configuration import APP_CONFIG try: - geDb = DatabaseConnector( + waDb = DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbDatabase=GRAPHICAL_EDITOR_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), @@ -156,69 +134,116 @@ def onMandateDelete(mandateId: str, instances: list) -> None: userId=None, ) - if not geDb._ensureTableExists(AutoWorkflow): + if not waDb._ensureTableExists(AutoWorkflow): return - geInstances = [ - inst for inst in instances - if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == "graphicalEditor" - ] + workflows = waDb.getRecordset(AutoWorkflow, recordFilter={ + "mandateId": mandateId, + }) or [] totalDeleted = 0 - for inst in geInstances: - instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) - if not instId: + for wf in workflows: + wfId = wf.get("id") + if not wfId: continue - workflows = geDb.getRecordset(AutoWorkflow, recordFilter={ - "mandateId": mandateId, - "featureInstanceId": instId, - }) or [] + for v in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: + waDb.recordDelete(AutoVersion, v.get("id")) - for wf in workflows: - wfId = wf.get("id") - if not wfId: - continue + for run in waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []: + runId = run.get("id") + for sl in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: + waDb.recordDelete(AutoStepLog, sl.get("id")) + waDb.recordDelete(AutoRun, runId) - for v in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: - geDb.recordDelete(AutoVersion, v.get("id")) + for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: + waDb.recordDelete(AutoTask, task.get("id")) - for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []: - runId = run.get("id") - for sl in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: - geDb.recordDelete(AutoStepLog, sl.get("id")) - geDb.recordDelete(AutoRun, runId) - - for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: - geDb.recordDelete(AutoTask, task.get("id")) - - geDb.recordDelete(AutoWorkflow, wfId) - totalDeleted += 1 + waDb.recordDelete(AutoWorkflow, wfId) + totalDeleted += 1 if totalDeleted: - logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) in Greenfield DB for mandate {mandateId}") - geDb.close() + logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) for mandate {mandateId}") + waDb.close() except Exception as e: - logger.warning(f"Failed to cascade-delete graphical editor data for mandate {mandateId}: {e}") + logger.warning(f"Failed to cascade-delete workflow automation data for mandate {mandateId}: {e}") + + +def _migrateRbacNamespace() -> None: + """Migrate legacy AccessRule objectKeys to the canonical workflowAutomation namespace. + + Idempotent: silently returns when no old-prefix records remain. + Must NOT crash the boot process — all exceptions are caught and logged. + """ + import psycopg2 + from modules.shared.configuration import APP_CONFIG + + _REPLACEMENTS = [ + ("resource.feature.graphicalEditor.", "resource.system.workflowAutomation."), + ("ui.feature.graphicalEditor.", "ui.system.workflowAutomation."), + ("resource.store.graphicalEditor", "resource.store.workflowAutomation"), + ] + + try: + conn = psycopg2.connect( + host=APP_CONFIG.get("DB_HOST", "localhost"), + port=int(APP_CONFIG.get("DB_PORT", "5432")), + user=APP_CONFIG.get("DB_USER"), + password=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbname="poweron_app", + ) + conn.autocommit = False + cur = conn.cursor() + + totalUpdated = 0 + for oldPrefix, newPrefix in _REPLACEMENTS: + cur.execute( + 'SELECT id, "objectKey" FROM "AccessRule" WHERE "objectKey" LIKE %s', + (f"{oldPrefix}%",), + ) + rows = cur.fetchall() + if not rows: + continue + + for rowId, objectKey in rows: + newKey = objectKey.replace(oldPrefix, newPrefix, 1) + cur.execute( + 'UPDATE "AccessRule" SET "objectKey" = %s WHERE id = %s', + (newKey, rowId), + ) + totalUpdated += 1 + + conn.commit() + cur.close() + conn.close() + + if totalUpdated: + logger.info( + f"RBAC namespace migration: updated {totalUpdated} AccessRule record(s) " + f"from legacy → workflowAutomation" + ) + except Exception as e: + logger.warning(f"RBAC namespace migration failed (non-critical): {e}") def onBootstrap() -> None: """Seed system workflow templates and sync feature template workflows on boot.""" + _migrateRbacNamespace() + from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG try: - greenfieldDb = DatabaseConnector( + waDb = DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbDatabase=GRAPHICAL_EDITOR_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), ) - greenfieldDb._ensureTableExists(AutoWorkflow) + waDb._ensureTableExists(AutoWorkflow) - # --- Seed system templates --- - existing = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={ + existing = waDb.getRecordset(AutoWorkflow, recordFilter={ "isTemplate": True, "templateScope": "system", }) @@ -230,13 +255,12 @@ def onBootstrap() -> None: if tpl["label"] in existingLabels: continue tpl["id"] = str(uuid.uuid4()) - greenfieldDb.recordCreate(AutoWorkflow, tpl) + waDb.recordCreate(AutoWorkflow, tpl) created += 1 if created: logger.info(f"Bootstrapped {created} system workflow template(s)") - # --- Sync feature template workflows --- from modules.system.registry import loadFeatureMainModules mainModules = loadFeatureMainModules() @@ -257,7 +281,7 @@ def onBootstrap() -> None: if templatesBySourceId: updated = 0 for sourceId, tpl in templatesBySourceId.items(): - instances = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={ + instances = waDb.getRecordset(AutoWorkflow, recordFilter={ "templateSourceId": sourceId, "isTemplate": False, }) @@ -285,25 +309,25 @@ def onBootstrap() -> None: if existingGraph == newGraph: continue - greenfieldDb.recordModify(AutoWorkflow, instId, {"graph": newGraph}) + waDb.recordModify(AutoWorkflow, instId, {"graph": newGraph}) updated += 1 if updated: logger.info(f"Synced {updated} workflow(s) with current feature templates") - greenfieldDb.close() + waDb.close() except Exception as e: - logger.warning(f"GraphicalEditor bootstrap failed: {e}") + logger.warning(f"WorkflowAutomation bootstrap failed: {e}") def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, templateWorkflows: list) -> int: """Create workflow instances from template definitions when a feature instance is created.""" - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface from modules.security.rootAccess import getRootUser from modules.shared.i18nRegistry import resolveText rootUser = getRootUser() - geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId) + waInterface = _getWorkflowAutomationInterface(rootUser, mandateId, instanceId) copied = 0 for template in templateWorkflows: @@ -315,7 +339,7 @@ def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, template label = resolveText(template.get("label")) - geInterface.createWorkflow({ + waInterface.createWorkflow({ "label": label, "graph": graph, "tags": template.get("tags", [f"feature:{featureCode}"]), @@ -395,8 +419,3 @@ def _buildSystemTemplates(): "invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}], }, ] - - -def getResourceObjects() -> List[Dict[str, Any]]: - """Return resource objects for RBAC catalog registration.""" - return RESOURCE_OBJECTS diff --git a/modules/workflowAutomation/scheduler/__init__.py b/modules/workflowAutomation/scheduler/__init__.py new file mode 100644 index 00000000..d5178091 --- /dev/null +++ b/modules/workflowAutomation/scheduler/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) 2025 Patrick Motsch +# Workflow Scheduler — consolidated scheduler with v1 incremental sync patterns. +from modules.workflowAutomation.scheduler.mainScheduler import ( + WorkflowScheduler, + start, + stop, + syncNow, + setMainLoop, + notifyRunFailed, + setOnRunFailedCallback, +) diff --git a/modules/features/graphicalEditor/emailPoller.py b/modules/workflowAutomation/scheduler/emailPoller.py similarity index 94% rename from modules/features/graphicalEditor/emailPoller.py rename to modules/workflowAutomation/scheduler/emailPoller.py index 7c769463..944135bc 100644 --- a/modules/features/graphicalEditor/emailPoller.py +++ b/modules/workflowAutomation/scheduler/emailPoller.py @@ -25,9 +25,9 @@ async def _pollEmailWaits(eventUser) -> None: Stops the poller when no runs are waiting. """ try: - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface as getAutomation2Interface - from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices as getAutomation2Services - from modules.workflows.automation2.executionEngine import executeGraph + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.engine.executionEngine import executeGraph from modules.workflows.processing.shared.methodDiscovery import discoverMethods from modules.interfaces.interfaceDbApp import getRootInterface @@ -36,7 +36,7 @@ async def _pollEmailWaits(eventUser) -> None: logger.warning("Email poller: root interface not available") return # Use eventUser - getRunsWaitingForEmail queries by status only - a2 = getAutomation2Interface(eventUser, mandateId="", featureInstanceId="") + a2 = _getWorkflowAutomationInterface(eventUser, mandateId="", featureInstanceId="") runs = a2.getRunsWaitingForEmail() if not runs: # No workflows waiting for email - stop the poller @@ -77,7 +77,7 @@ async def _pollEmailWaits(eventUser) -> None: continue # Get workflow (need scoped interface for mandate/instance) - a2_scoped = getAutomation2Interface(eventUser, mandateId=mandate_id, featureInstanceId=instance_id) + a2_scoped = _getWorkflowAutomationInterface(eventUser, mandateId=mandate_id, featureInstanceId=instance_id) wf = a2_scoped.getWorkflow(workflow_id) if not wf or not wf.get("graph"): logger.warning("Email wait run %s: workflow %s not found or has no graph", run_id, workflow_id) @@ -90,7 +90,7 @@ async def _pollEmailWaits(eventUser) -> None: logger.warning("Email wait run %s: paused at email.searchEmail (should not wait) – skipping", run_id) continue - services = getAutomation2Services(owner, mandateId=mandate_id, featureInstanceId=instance_id) + services = _getWorkflowAutomationServices(owner, mandateId=mandate_id, featureInstanceId=instance_id) discoverMethods(services) # Build filter with receivedDateTime – only emails received at or after baseline (new emails) diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflowAutomation/scheduler/mainScheduler.py similarity index 91% rename from modules/workflows/scheduler/mainScheduler.py rename to modules/workflowAutomation/scheduler/mainScheduler.py index 11544015..2f45932e 100644 --- a/modules/workflows/scheduler/mainScheduler.py +++ b/modules/workflowAutomation/scheduler/mainScheduler.py @@ -22,8 +22,8 @@ logger = logging.getLogger(__name__) _main_loop = None -JOB_ID_PREFIX = "graphicalEditor." -_CALLBACK_NAME = "graphicalEditor.workflow.changed" +JOB_ID_PREFIX = "workflowAutomation." +_CALLBACK_NAME = "workflowAutomation.workflow.changed" def _setMainLoop(loop) -> None: @@ -76,8 +76,8 @@ class WorkflowScheduler: Incremental sync: only re-register jobs whose eventId has changed. Uses AutoWorkflow.eventId for change detection (v1 pattern). """ - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getAllWorkflowsForScheduling - from modules.workflows.automation2.scheduleCron import parse_cron_to_kwargs + from modules.interfaces.interfaceWorkflowAutomation import getAllWorkflowsForScheduling + from modules.workflowAutomation.engine.scheduleCron import parse_cron_to_kwargs items = getAllWorkflowsForScheduling() logger.info("WorkflowScheduler: found %d workflow(s) with trigger.schedule+cron", len(items)) @@ -174,7 +174,7 @@ class WorkflowScheduler: currentEventId = workflow.get("eventId") if currentEventId != jobId: try: - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface from modules.interfaces.interfaceDbApp import getRootInterface root = getRootInterface() eventUser = root.getUserByUsername("event") if root else self._eventUser @@ -182,7 +182,7 @@ class WorkflowScheduler: return mandateId = workflow.get("mandateId", "") instanceId = workflow.get("featureInstanceId", "") - iface = getGraphicalEditorInterface(eventUser, mandateId, instanceId) + iface = _getWorkflowAutomationInterface(eventUser, mandateId, instanceId) iface.updateWorkflow(workflowId, {"eventId": jobId}) except Exception as e: logger.debug("WorkflowScheduler: could not update eventId for %s: %s", workflowId, e) @@ -205,14 +205,14 @@ class WorkflowScheduler: logger.error("WorkflowScheduler: event user not available") return - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface - from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices - from modules.workflows.automation2.executionEngine import executeGraph + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.engine.executionEngine import executeGraph from modules.workflows.processing.shared.methodDiscovery import discoverMethods - from modules.features.graphicalEditor.entryPoints import find_invocation - from modules.workflows.automation2.runEnvelope import default_run_envelope, normalize_run_envelope + from modules.workflowAutomation.editor.entryPoints import find_invocation + from modules.workflowAutomation.engine.runEnvelope import default_run_envelope, normalize_run_envelope - iface = getGraphicalEditorInterface(eventUser, mandateId, instanceId) + iface = _getWorkflowAutomationInterface(eventUser, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf or not wf.get("graph"): logger.warning("WorkflowScheduler: workflow %s not found or no graph", workflowId) @@ -226,7 +226,7 @@ class WorkflowScheduler: logger.info("WorkflowScheduler: entry point %s disabled for workflow %s", entryPointId, workflowId) return - services = getGraphicalEditorServices( + services = _getWorkflowAutomationServices( eventUser, mandateId=mandateId, featureInstanceId=instanceId, @@ -336,7 +336,7 @@ def _cronToIntervalSeconds(cron: str): def notifyRunFailed(workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None) -> None: """Notify on workflow run failure: emit event, create in-app notification, trigger email subscription.""" try: - eventManager.emit("graphicalEditor.run.failed", { + eventManager.emit("workflowAutomation.run.failed", { "workflowId": workflowId, "runId": runId, "error": error, @@ -362,12 +362,12 @@ def _createRunFailedNotification( if not rootInterface: return - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface eventUser = rootInterface.getUserByUsername("event") if not eventUser: return - iface = getGraphicalEditorInterface(eventUser, mandateId or "", "") + iface = _getWorkflowAutomationInterface(eventUser, mandateId or "", "") wf = iface.getWorkflow(workflowId) if not wf: return diff --git a/modules/workflows/automation2/__init__.py b/modules/workflows/automation2/__init__.py index 0656ab39..28ce2eea 100644 --- a/modules/workflows/automation2/__init__.py +++ b/modules/workflows/automation2/__init__.py @@ -1,2 +1,13 @@ # Copyright (c) 2025 Patrick Motsch -# automation2 - n8n-style graph execution engine. +# Re-export shim: modules moved to modules.workflowAutomation.engine +# This file preserves backwards compatibility for existing imports. + +from modules.workflowAutomation.engine.executionEngine import * # noqa: F401,F403 +from modules.workflowAutomation.engine.graphUtils import * # noqa: F401,F403 +from modules.workflowAutomation.engine.runEnvelope import * # noqa: F401,F403 +from modules.workflowAutomation.engine.scheduleCron import * # noqa: F401,F403 +from modules.workflowAutomation.engine.runFileLogger import * # noqa: F401,F403 +from modules.workflowAutomation.engine.pickNotPushMigration import * # noqa: F401,F403 +from modules.workflowAutomation.engine.featureInstanceRefMigration import * # noqa: F401,F403 +from modules.workflowAutomation.engine.workflowArtifactVisibility import * # noqa: F401,F403 +from modules.workflowAutomation.engine.clickupTaskUpdateMerge import * # noqa: F401,F403 diff --git a/modules/workflows/automation2/executors/__init__.py b/modules/workflows/automation2/executors/__init__.py index 4d2180c3..1c2b18d4 100644 --- a/modules/workflows/automation2/executors/__init__.py +++ b/modules/workflows/automation2/executors/__init__.py @@ -1,11 +1,16 @@ # Copyright (c) 2025 Patrick Motsch -# Executors for automation2 node types. +# Re-export shim: executors moved to modules.workflowAutomation.engine.executors +# This file preserves backwards compatibility for existing imports. -from .triggerExecutor import TriggerExecutor -from .flowExecutor import FlowExecutor -from .actionNodeExecutor import ActionNodeExecutor -from .inputExecutor import InputExecutor, PauseForHumanTaskError, PauseForEmailWaitError -from .dataExecutor import DataExecutor +from modules.workflowAutomation.engine.executors import ( # noqa: F401 + TriggerExecutor, + FlowExecutor, + ActionNodeExecutor, + InputExecutor, + DataExecutor, + PauseForHumanTaskError, + PauseForEmailWaitError, +) __all__ = [ "TriggerExecutor", diff --git a/modules/workflows/methods/_actionSignatureValidator.py b/modules/workflows/methods/_actionSignatureValidator.py index 25be8175..aeeb49c1 100644 --- a/modules/workflows/methods/_actionSignatureValidator.py +++ b/modules/workflows/methods/_actionSignatureValidator.py @@ -25,7 +25,7 @@ from modules.datamodels.datamodelWorkflowActions import ( WorkflowActionDefinition, WorkflowActionParameter, ) -from modules.features.graphicalEditor.portTypes import ( +from modules.workflowAutomation.editor.portTypes import ( PORT_TYPE_CATALOG, PRIMITIVE_TYPES, _stripContainer, diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py index 57670f61..5ab10077 100644 --- a/modules/workflows/methods/methodBase.py +++ b/modules/workflows/methods/methodBase.py @@ -240,7 +240,7 @@ class MethodBase: runtime structural validation is handled by the workflow engine / port-schema layer, not at the action-call boundary. """ - from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG if expectedType in PORT_TYPE_CATALOG: return value diff --git a/modules/workflows/methods/methodContext/actions/setContext.py b/modules/workflows/methods/methodContext/actions/setContext.py index 24e10fc8..62435e38 100644 --- a/modules/workflows/methods/methodContext/actions/setContext.py +++ b/modules/workflows/methods/methodContext/actions/setContext.py @@ -320,7 +320,7 @@ def _pause_for_human_tasks( ) task_id = str((task or {}).get("id") or "") ordered_ids = [n.get("id") for n in (run_context.get("_orderedNodes") or []) if n.get("id")] - from modules.workflows.automation2.graphicalEditorRunFileLogger import merge_persisted_run_context + from modules.workflowAutomation.engine.runFileLogger import merge_persisted_run_context _pause_ctx = merge_persisted_run_context( iface, diff --git a/modules/workflows/processing/shared/parameterValidation.py b/modules/workflows/processing/shared/parameterValidation.py index f86b605f..ea182212 100644 --- a/modules/workflows/processing/shared/parameterValidation.py +++ b/modules/workflows/processing/shared/parameterValidation.py @@ -64,7 +64,7 @@ def _isRefSchema(typeStr: str) -> bool: """ if not typeStr or not typeStr.endswith("Ref"): return False - from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG schema = PORT_TYPE_CATALOG.get(typeStr) if schema is None: return False diff --git a/modules/workflows/scheduler/__init__.py b/modules/workflows/scheduler/__init__.py index e2b0f5de..4e814ab5 100644 --- a/modules/workflows/scheduler/__init__.py +++ b/modules/workflows/scheduler/__init__.py @@ -1,2 +1,11 @@ # Copyright (c) 2025 Patrick Motsch -# Workflow Scheduler - consolidated scheduler with v1 incremental sync patterns +# Re-export shim — real implementation moved to modules.workflowAutomation.scheduler +from modules.workflowAutomation.scheduler.mainScheduler import ( + WorkflowScheduler, + start, + stop, + syncNow, + setMainLoop, + notifyRunFailed, + setOnRunFailedCallback, +) diff --git a/tests/demo/test_demo_bootstrap.py b/tests/demo/test_demo_bootstrap.py index 3ac6073e..45db18c7 100644 --- a/tests/demo/test_demo_bootstrap.py +++ b/tests/demo/test_demo_bootstrap.py @@ -48,13 +48,13 @@ class TestDemoBootstrap: memberships = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mid}) assert len(memberships) >= 1, f"User not member of mandate {mandate.get('label')}" - @pytest.mark.parametrize("featureCode", ["workspace", "trustee", "graphicalEditor", "neutralization"]) + @pytest.mark.parametrize("featureCode", ["workspace", "trustee", "neutralization"]) def test_happylifeFeaturesExist(self, db, mandateHappylife, featureCode): mid = mandateHappylife.get("id") instances = _getFeatureInstances(db, mid, featureCode) assert len(instances) >= 1, f"Feature '{featureCode}' missing in HappyLife AG" - @pytest.mark.parametrize("featureCode", ["workspace", "trustee", "graphicalEditor", "neutralization"]) + @pytest.mark.parametrize("featureCode", ["workspace", "trustee", "neutralization"]) def test_alpinaFeaturesExist(self, db, mandateAlpina, featureCode): mid = mandateAlpina.get("id") instances = _getFeatureInstances(db, mid, featureCode) diff --git a/tests/demo/test_demo_uc1_trustee.py b/tests/demo/test_demo_uc1_trustee.py index 54d2ac70..f7fd2ce0 100644 --- a/tests/demo/test_demo_uc1_trustee.py +++ b/tests/demo/test_demo_uc1_trustee.py @@ -50,7 +50,7 @@ class TestSystemWorkflowTemplates: def test_systemTemplatesExist(self, db): """System workflow templates should exist (created by system bootstrap, not demo config).""" - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow + from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow try: templates = db.getRecordset(AutoWorkflow, recordFilter={"isTemplate": True, "templateScope": "system"}) except Exception: diff --git a/tests/demo/test_pwg_demo_bootstrap.py b/tests/demo/test_pwg_demo_bootstrap.py index 94c890e4..7bc38345 100644 --- a/tests/demo/test_pwg_demo_bootstrap.py +++ b/tests/demo/test_pwg_demo_bootstrap.py @@ -105,7 +105,7 @@ class TestPwgDemoBootstrap: @pytest.mark.parametrize( "featureCode", - ["workspace", "trustee", "graphicalEditor", "neutralization"], + ["workspace", "trustee", "neutralization"], ) def test_pwgFeaturesExist(self, db, mandatePwg, featureCode): instances = _getFeatureInstances(db, mandatePwg.get("id"), featureCode) @@ -116,8 +116,8 @@ class TestPwgDemoBootstrap: "mandateId": mandatePwg.get("id"), }) or [] codes = sorted({i.get("featureCode") for i in instances}) - assert codes == ["graphicalEditor", "neutralization", "trustee", "workspace"], ( - f"Expected exactly 4 feature instances, got {codes}" + assert codes == ["neutralization", "trustee", "workspace"], ( + f"Expected exactly 3 feature instances, got {codes}" ) @@ -183,20 +183,15 @@ class TestPwgTrusteeSeed: class TestPwgPilotWorkflow: def test_pilotWorkflowImported(self, db, mandatePwg): - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow - from modules.demoConfigs.pwgDemo2026 import _openGraphicalEditorDb - instances = _getFeatureInstances(db, mandatePwg.get("id"), "graphicalEditor") - assert instances, "No graphicalEditor instance for PWG" - instId = instances[0].get("id") - geDb = _openGraphicalEditorDb() + from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow + from modules.demoConfigs.pwgDemo2026 import _openWorkflowAutomationDb + geDb = _openWorkflowAutomationDb() wfs = geDb.getRecordset(AutoWorkflow, recordFilter={ "mandateId": mandatePwg.get("id"), - "featureInstanceId": instId, "label": "PWG Pilot: Jahresmietzinsbestätigung", }) or [] assert len(wfs) == 1, f"Expected exactly 1 PWG pilot workflow, got {len(wfs)}" wf = wfs[0] - # AC 10: imports must be inactive by default assert wf.get("active") is False, "PWG pilot workflow must be imported with active=false" graph = wf.get("graph") or {} assert (graph.get("nodes") or []), "PWG pilot workflow has no nodes" diff --git a/tests/integration/automation2/test_pick_not_push_migration_v2.py b/tests/integration/automation2/test_pick_not_push_migration_v2.py index 9b98e0ec..fb109337 100644 --- a/tests/integration/automation2/test_pick_not_push_migration_v2.py +++ b/tests/integration/automation2/test_pick_not_push_migration_v2.py @@ -25,11 +25,11 @@ from typing import Any, Dict import pytest -from modules.workflows.automation2.featureInstanceRefMigration import ( +from modules.workflowAutomation.engine.featureInstanceRefMigration import ( materializeFeatureInstanceRefs, ) -from modules.workflows.automation2.graphUtils import resolveParameterReferences -from modules.workflows.automation2.pickNotPushMigration import materializeConnectionRefs +from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences +from modules.workflowAutomation.engine.pickNotPushMigration import materializeConnectionRefs _TRUSTEE_INSTANCE_UUID = "f1e2d3c4-b5a6-7890-1234-567890abcdef" diff --git a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py index fcda01e4..b7b952b8 100644 --- a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py +++ b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py @@ -43,8 +43,8 @@ from typing import Any, Dict, List, Optional import pytest -from modules.workflows.automation2.executionEngine import executeGraph -from modules.workflows.automation2.runEnvelope import default_run_envelope +from modules.workflowAutomation.engine.executionEngine import executeGraph +from modules.workflowAutomation.engine.runEnvelope import default_run_envelope _TRUSTEE_INSTANCE_UUID = "11111111-2222-3333-4444-555555555555" diff --git a/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py b/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py index 751de6d4..3fc75f54 100644 --- a/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py +++ b/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py @@ -4,10 +4,10 @@ import pytest from unittest.mock import MagicMock -from modules.workflows.automation2.executionEngine import executeGraph -from modules.workflows.automation2.graphUtils import buildConnectionMap, getInputSources -from modules.workflows.automation2.executors.dataExecutor import DataExecutor -from modules.workflows.automation2.runEnvelope import default_run_envelope +from modules.workflowAutomation.engine.executionEngine import executeGraph +from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getInputSources +from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor +from modules.workflowAutomation.engine.runEnvelope import default_run_envelope def _minimal_services(): diff --git a/tests/unit/graphicalEditor/test_action_node_connection_provenance.py b/tests/unit/graphicalEditor/test_action_node_connection_provenance.py index b04dd594..610d35c9 100644 --- a/tests/unit/graphicalEditor/test_action_node_connection_provenance.py +++ b/tests/unit/graphicalEditor/test_action_node_connection_provenance.py @@ -1,5 +1,5 @@ # Copyright (c) 2025 Patrick Motsch -from modules.workflows.automation2.executors.actionNodeExecutor import _buildConnectionRefDict +from modules.workflowAutomation.engine.executors.actionNodeExecutor import _buildConnectionRefDict def test_build_connection_ref_dict_from_logical_string(): diff --git a/tests/unit/graphicalEditor/test_adapter_validator.py b/tests/unit/graphicalEditor/test_adapter_validator.py index 5ee5abef..605251c6 100644 --- a/tests/unit/graphicalEditor/test_adapter_validator.py +++ b/tests/unit/graphicalEditor/test_adapter_validator.py @@ -27,14 +27,14 @@ from modules.datamodels.datamodelWorkflowActions import ( WorkflowActionDefinition, WorkflowActionParameter, ) -from modules.features.graphicalEditor.adapterValidator import ( +from modules.workflowAutomation.editor.adapterValidator import ( AdapterValidationReport, _buildActionsRegistryFromMethods, _formatAdapterReport, _validateAdapterAgainstAction, _validateAllAdapters, ) -from modules.features.graphicalEditor.nodeAdapter import ( +from modules.workflowAutomation.editor.nodeAdapter import ( NodeAdapter, UserParamMapping, ) @@ -334,7 +334,7 @@ def test_staticNodesHaveNoDriftAgainstLiveMethods(): History: wiki/c-work/4-done/2026-04-adapter-drift-cleanup.md """ - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES instances = _instantiateLiveMethods() if not instances: diff --git a/tests/unit/graphicalEditor/test_condition_operator_catalog.py b/tests/unit/graphicalEditor/test_condition_operator_catalog.py index a1954448..ce02c083 100644 --- a/tests/unit/graphicalEditor/test_condition_operator_catalog.py +++ b/tests/unit/graphicalEditor/test_condition_operator_catalog.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch """Tests for backend-driven condition operator catalog.""" -from modules.features.graphicalEditor.conditionOperators import ( +from modules.workflowAutomation.editor.conditionOperators import ( CONDITION_OPERATOR_CATALOG, VALUE_KINDS, apply_condition_operator, diff --git a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py index 279c6da4..525faa4a 100644 --- a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py +++ b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py @@ -24,8 +24,8 @@ from __future__ import annotations import pytest -from modules.features.graphicalEditor.nodeDefinitions.redmine import REDMINE_NODES -from modules.features.graphicalEditor.nodeDefinitions.trustee import TRUSTEE_NODES +from modules.workflowAutomation.editor.nodeDefinitions.redmine import REDMINE_NODES +from modules.workflowAutomation.editor.nodeDefinitions.trustee import TRUSTEE_NODES def _featureInstanceParam(node: dict) -> dict | None: diff --git a/tests/unit/graphicalEditor/test_node_adapter.py b/tests/unit/graphicalEditor/test_node_adapter.py index 64915a17..3c18f438 100644 --- a/tests/unit/graphicalEditor/test_node_adapter.py +++ b/tests/unit/graphicalEditor/test_node_adapter.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from modules.features.graphicalEditor.nodeAdapter import ( +from modules.workflowAutomation.editor.nodeAdapter import ( NodeAdapter, UserParamMapping, _adapterFromLegacyNode, diff --git a/tests/unit/graphicalEditor/test_portTypes_catalog.py b/tests/unit/graphicalEditor/test_portTypes_catalog.py index 11967376..0506be27 100644 --- a/tests/unit/graphicalEditor/test_portTypes_catalog.py +++ b/tests/unit/graphicalEditor/test_portTypes_catalog.py @@ -6,7 +6,7 @@ Catalog integrity + new Phase-1 schemas import pytest -from modules.features.graphicalEditor.portTypes import ( +from modules.workflowAutomation.editor.portTypes import ( PORT_TYPE_CATALOG, PRIMITIVE_TYPES, PortField, diff --git a/tests/unit/graphicalEditor/test_port_schema_recursive.py b/tests/unit/graphicalEditor/test_port_schema_recursive.py index b3ae22c6..7884109e 100644 --- a/tests/unit/graphicalEditor/test_port_schema_recursive.py +++ b/tests/unit/graphicalEditor/test_port_schema_recursive.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch """Port type catalog: nested provenance schemas (Typed Generic Handover).""" -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, _defaultForType +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, _defaultForType def test_connection_ref_in_catalog(): diff --git a/tests/unit/graphicalEditor/test_resolve_value_kind.py b/tests/unit/graphicalEditor/test_resolve_value_kind.py index 35b53e07..497619e2 100644 --- a/tests/unit/graphicalEditor/test_resolve_value_kind.py +++ b/tests/unit/graphicalEditor/test_resolve_value_kind.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch """Tests for condition valueKind resolution.""" -from modules.features.graphicalEditor.conditionOperators import resolve_value_kind +from modules.workflowAutomation.editor.conditionOperators import resolve_value_kind def _graph(nodes, connections=None, target=None): diff --git a/tests/unit/graphicalEditor/test_route_options_feature_instance.py b/tests/unit/graphicalEditor/test_route_options_feature_instance.py deleted file mode 100644 index d626c135..00000000 --- a/tests/unit/graphicalEditor/test_route_options_feature_instance.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright (c) 2026 Patrick Motsch -# All rights reserved. -""" -Smoke test for the new ``GET /options/feature.instance`` endpoint that backs -the frontend ``FeatureInstancePicker`` (Schicht-4 / Phase-5 follow-up). - -A heavyweight HTTP integration test would need the full FastAPI client + -DB fixtures; this lightweight test asserts at the router level that the -endpoint exists with the expected method, path, and required query -parameter, so a refactor that drops or renames it fails loudly. - -Track-doc: ``wiki/c-work/2-build/2026-04-feature-instance-ref-adapter-migration.md``. -""" -from __future__ import annotations - -import pytest - -from modules.features.graphicalEditor.routeFeatureGraphicalEditor import router - - -def _findRoute(path: str, method: str = "GET"): - for route in router.routes: - # FastAPI routes expose `path` and `methods` attributes. - if getattr(route, "path", None) == path and method in ( - getattr(route, "methods", set()) or set() - ): - return route - return None - - -_ROUTE_PATH = "/api/workflows/{instanceId}/options/feature.instance" - - -def test_optionsFeatureInstanceRouteIsRegistered() -> None: - """The picker endpoint must be available at the documented path.""" - route = _findRoute(_ROUTE_PATH, "GET") - assert route is not None, ( - f"GET {_ROUTE_PATH} is not registered on graphicalEditor router. " - "The FeatureInstancePicker will fail to load mandate-scoped instances." - ) - - -def test_optionsFeatureInstanceRouteRequiresFeatureCode() -> None: - """``featureCode`` must be a required query parameter (no default).""" - route = _findRoute(_ROUTE_PATH, "GET") - assert route is not None - endpoint = route.endpoint - sig = __import__("inspect").signature(endpoint) - featureCode = sig.parameters.get("featureCode") - assert featureCode is not None, "featureCode parameter missing" - # FastAPI's Query(...) sentinel produces a FieldInfo whose `is_required()` - # returns True; older variants encoded the same intent via - # `default is Ellipsis` or `default.default is Ellipsis`. Accept any of - # those so the test stays robust across FastAPI/Pydantic versions. - default = featureCode.default - isRequiredFn = getattr(default, "is_required", None) - isRequired = ( - (callable(isRequiredFn) and isRequiredFn()) - or default is ... - or getattr(default, "default", None) is ... - ) - assert isRequired, ( - "featureCode must be a required Query parameter; otherwise the picker " - "could ask for ALL feature instances of the mandate, which is not the " - "intent of /options/feature.instance." - ) diff --git a/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py b/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py index 13072b3f..8e64367e 100644 --- a/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py +++ b/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch -from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths -from modules.workflows.automation2.graphUtils import parse_graph_defined_schema, validateGraph -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths +from modules.workflowAutomation.engine.graphUtils import parse_graph_defined_schema, validateGraph +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES def test_compute_upstream_paths_includes_form_dynamic_fields(): diff --git a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py index d1b6397c..36038ee1 100644 --- a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py +++ b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py @@ -20,10 +20,10 @@ Verifies that: import inspect -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG -from modules.workflows.automation2.executors import actionNodeExecutor as _actionExec -from modules.workflows.automation2.graphUtils import validateGraph +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG +from modules.workflowAutomation.engine.executors import actionNodeExecutor as _actionExec +from modules.workflowAutomation.engine.graphUtils import validateGraph def _node(nodeId: str) -> dict: diff --git a/tests/unit/nodeDefinitions/test_usesai_flag.py b/tests/unit/nodeDefinitions/test_usesai_flag.py index 1c7bbf99..bf578fd0 100644 --- a/tests/unit/nodeDefinitions/test_usesai_flag.py +++ b/tests/unit/nodeDefinitions/test_usesai_flag.py @@ -2,7 +2,7 @@ import pytest -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES def test_all_nodes_have_usesAi(): diff --git a/tests/unit/serviceAgent/test_workflow_tools_crud.py b/tests/unit/serviceAgent/test_workflow_tools_crud.py index 9ebe1df6..b578b1de 100644 --- a/tests/unit/serviceAgent/test_workflow_tools_crud.py +++ b/tests/unit/serviceAgent/test_workflow_tools_crud.py @@ -31,7 +31,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResul # --------------------------------------------------------------------------- class _FakeInterface: - """In-memory stand-in for ``GraphicalEditorObjects``. + """In-memory stand-in for ``WorkflowAutomationObjects``. Stores workflows by id and records every method call in ``self.calls`` so tests can assert on the parameters the tool layer forwarded. diff --git a/tests/unit/workflow/test_extract_content_handover.py b/tests/unit/workflow/test_extract_content_handover.py index c0009251..9153f350 100644 --- a/tests/unit/workflow/test_extract_content_handover.py +++ b/tests/unit/workflow/test_extract_content_handover.py @@ -395,7 +395,7 @@ def test_action_result_contract_new_extract_payload_keys(): def test_automation_workspace_suppresses_extract_artifacts(): - from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui + from modules.workflowAutomation.engine.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui assert suppress_workflow_file_in_workspace_ui({"fileName": "extracted_content_transient-abc_99.json"}) assert suppress_workflow_file_in_workspace_ui({"fileName": "extract_media_stem_uuid.png"}) diff --git a/tests/unit/workflow/test_flow_executor_conditions.py b/tests/unit/workflow/test_flow_executor_conditions.py index 70cc84f4..b16e8e5c 100644 --- a/tests/unit/workflow/test_flow_executor_conditions.py +++ b/tests/unit/workflow/test_flow_executor_conditions.py @@ -3,7 +3,7 @@ import pytest -from modules.workflows.automation2.executors.flowExecutor import FlowExecutor +from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor from modules.workflows.methods.methodContext.actions.extractContent import PRESENTATION_KIND diff --git a/tests/unit/workflow/test_node_combinations.py b/tests/unit/workflow/test_node_combinations.py index 2fd5dd00..15159048 100644 --- a/tests/unit/workflow/test_node_combinations.py +++ b/tests/unit/workflow/test_node_combinations.py @@ -14,8 +14,8 @@ import json import pytest -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG from modules.workflows.methods.methodContext.actions.extractContent import ( PRESENTATION_KIND, build_presentation_envelope_from_plain_text, @@ -47,7 +47,7 @@ def _ai_output(response: str) -> dict: def test_extract_to_file_create_recommended_ref_is_data(): """materializeRecommendedDataPickRef must resolve extractContent port 0 to path ['data'].""" - from modules.workflows.automation2.pickNotPushMigration import materializeRecommendedDataPickRef + from modules.workflowAutomation.engine.pickNotPushMigration import materializeRecommendedDataPickRef graph = { "nodes": [ @@ -90,7 +90,7 @@ def test_extract_output_response_is_empty(): def test_extract_primary_text_ref_override_materializes_to_data(): """When ai.prompt connects to extractContent, primaryTextRef must resolve to ['data'].""" - from modules.workflows.automation2.pickNotPushMigration import materializePrimaryTextHandover + from modules.workflowAutomation.engine.pickNotPushMigration import materializePrimaryTextHandover graph = { "nodes": [ @@ -183,7 +183,7 @@ async def test_merge_context_items_without_success_key_are_included(): def test_ai_prompt_primary_text_ref_materializes_to_response(): """primaryTextRef from ai.prompt output must resolve to ['response'].""" - from modules.workflows.automation2.pickNotPushMigration import materializePrimaryTextHandover + from modules.workflowAutomation.engine.pickNotPushMigration import materializePrimaryTextHandover graph = { "nodes": [ @@ -345,7 +345,7 @@ def test_ai_result_catalog_has_data_field(): def test_output_schema_for_transform_context_is_action_result(): """_outputSchemaForNode must return ActionResult for context.transformContext.""" - from modules.workflows.automation2.executionEngine import _outputSchemaForNode + from modules.workflowAutomation.engine.executionEngine import _outputSchemaForNode schema = _outputSchemaForNode("context.transformContext") assert schema == "ActionResult", ( f"Expected ActionResult, got {schema!r}. fromGraph port must use fromGraphResultSchema." @@ -357,19 +357,19 @@ def test_output_schema_for_transform_context_is_action_result(): # --------------------------------------------------------------------------- def test_flow_merge_is_barrier(): - from modules.workflows.automation2.executionEngine import _isBarrierNode + from modules.workflowAutomation.engine.executionEngine import _isBarrierNode assert _isBarrierNode("flow.merge") is True def test_context_merge_context_is_not_barrier(): """context.mergeContext is not a barrier — it receives data via dataSource DataRef.""" - from modules.workflows.automation2.executionEngine import _isBarrierNode + from modules.workflowAutomation.engine.executionEngine import _isBarrierNode assert _isBarrierNode("context.mergeContext") is False def test_no_node_named_is_merge_node_in_engine(): """Legacy _isMergeNode alias must be removed from executionEngine.""" - import modules.workflows.automation2.executionEngine as eng + import modules.workflowAutomation.engine.executionEngine as eng assert not hasattr(eng, "_isMergeNode"), "_isMergeNode legacy alias must be deleted" diff --git a/tests/unit/workflow/test_phase3_context_node.py b/tests/unit/workflow/test_phase3_context_node.py index 76fbc972..49500bc2 100644 --- a/tests/unit/workflow/test_phase3_context_node.py +++ b/tests/unit/workflow/test_phase3_context_node.py @@ -1,9 +1,9 @@ # Tests for Phase 3: context.extractContent node, port types, executor dispatch. import pytest -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG -from modules.workflows.automation2.udmUpstreamShapes import ( +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG +from modules.workflowAutomation.engine.udmUpstreamShapes import ( _coerceConsolidateResultInput, _coerceUdmDocumentInput, _coerceUdmNodeListInput, @@ -89,8 +89,8 @@ def test_coerceConsolidateResult(): def test_getExecutor_dispatches_context(): - from modules.workflows.automation2.executionEngine import _getExecutor - from modules.workflows.automation2.executors import ActionNodeExecutor + from modules.workflowAutomation.engine.executionEngine import _getExecutor + from modules.workflowAutomation.engine.executors import ActionNodeExecutor executor = _getExecutor("context.extractContent", None) assert isinstance(executor, ActionNodeExecutor) diff --git a/tests/unit/workflow/test_phase4_workflow_nodes.py b/tests/unit/workflow/test_phase4_workflow_nodes.py index eb478bda..24a29d1f 100644 --- a/tests/unit/workflow/test_phase4_workflow_nodes.py +++ b/tests/unit/workflow/test_phase4_workflow_nodes.py @@ -1,7 +1,7 @@ # Tests for Phase 4: data.consolidate, ai.consolidate, flow.loop level/concurrency, flow.merge dynamic. import pytest -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES class TestNodeDefinitions: @@ -63,7 +63,7 @@ class TestNodeDefinitions: class TestDataConsolidateExecutor: async def test_consolidate_table_mode(self): - from modules.workflows.automation2.executors.dataExecutor import DataExecutor + from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor ex = DataExecutor() node = {"type": "data.consolidate", "id": "dc1", "parameters": {"mode": "table"}} ctx = {"nodeOutputs": {"src": {"items": [{"a": 1, "b": 2}, {"a": 3, "b": 4}], "count": 2}}, "inputSources": {"dc1": {0: ("src", 0)}}} @@ -75,7 +75,7 @@ class TestDataConsolidateExecutor: assert len(result["result"]["rows"]) == 2 async def test_consolidate_concat_mode(self): - from modules.workflows.automation2.executors.dataExecutor import DataExecutor + from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor ex = DataExecutor() node = {"type": "data.consolidate", "id": "dc1", "parameters": {"mode": "concat", "separator": "; "}} ctx = {"nodeOutputs": {"src": {"items": ["hello", "world"], "count": 2}}, "inputSources": {"dc1": {0: ("src", 0)}}} @@ -84,7 +84,7 @@ class TestDataConsolidateExecutor: assert result["result"] == "hello; world" async def test_consolidate_merge_mode(self): - from modules.workflows.automation2.executors.dataExecutor import DataExecutor + from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor ex = DataExecutor() node = {"type": "data.consolidate", "id": "dc1", "parameters": {"mode": "merge"}} ctx = {"nodeOutputs": {"src": {"items": [{"a": 1}, {"b": 2}, {"a": 99}], "count": 3}}, "inputSources": {"dc1": {0: ("src", 0)}}} @@ -98,7 +98,7 @@ class TestFlowLoopUdmLevel: """Unit tests for FlowExecutor._resolveUdmLevel (bypass resolveParameterReferences).""" def test_resolveUdmLevel_structural_nodes(self): - from modules.workflows.automation2.executors.flowExecutor import FlowExecutor + from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor ex = FlowExecutor() udm = { "id": "d1", "role": "document", @@ -112,7 +112,7 @@ class TestFlowLoopUdmLevel: assert result[0]["id"] == "p1" def test_resolveUdmLevel_content_blocks(self): - from modules.workflows.automation2.executors.flowExecutor import FlowExecutor + from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor ex = FlowExecutor() udm = { "id": "d1", "role": "document", @@ -130,7 +130,7 @@ class TestFlowLoopUdmLevel: assert len(result) == 3 def test_resolveUdmLevel_documents(self): - from modules.workflows.automation2.executors.flowExecutor import FlowExecutor + from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor ex = FlowExecutor() archive = { "id": "a1", "role": "archive", @@ -145,20 +145,20 @@ class TestFlowLoopUdmLevel: @pytest.mark.asyncio async def test_loop_auto_dict_with_children(self): - from modules.workflows.automation2.executors.flowExecutor import FlowExecutor + from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor ex = FlowExecutor() udm = {"id": "d1", "role": "document", "children": [{"id": "p1"}, {"id": "p2"}]} node = {"type": "flow.loop", "id": "loop1", "parameters": {"items": "direct"}} ctx = {"nodeOutputs": {"loop1": udm, "direct": udm}, "connectionMap": {}, "inputSources": {"loop1": {0: ("direct", 0)}}} from unittest.mock import patch - with patch("modules.workflows.automation2.graphUtils.resolveParameterReferences", return_value=udm): + with patch("modules.workflowAutomation.engine.graphUtils.resolveParameterReferences", return_value=udm): result = await ex.execute(node, ctx) assert result["count"] == 2 @pytest.mark.asyncio async def test_loop_every_nth_stride(self): - from modules.workflows.automation2.executors.flowExecutor import FlowExecutor + from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor ex = FlowExecutor() node = {"type": "flow.loop", "id": "loop1", "parameters": { "items": {"type": "value", "value": [10, 20, 30, 40, 50]}, @@ -175,7 +175,7 @@ class TestFlowLoopUdmLevel: class TestDataFilterUdm: async def test_filter_by_udm_content_type(self): - from modules.workflows.automation2.executors.dataExecutor import DataExecutor + from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor ex = DataExecutor() udmData = { "id": "d1", "role": "document", diff --git a/tests/unit/workflow/test_phase5_highvol.py b/tests/unit/workflow/test_phase5_highvol.py index 382c273b..45079fb4 100644 --- a/tests/unit/workflow/test_phase5_highvol.py +++ b/tests/unit/workflow/test_phase5_highvol.py @@ -1,7 +1,7 @@ # Tests for Phase 5: Loop concurrency, StepLog batching, streaming aggregate. import pytest -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES def test_loop_concurrency_param_default_1(): @@ -15,7 +15,7 @@ def test_loop_concurrency_param_default_1(): def test_executionEngine_has_batch_threshold(): """Verify STEPLOG_BATCH_THRESHOLD and AGGREGATE_FLUSH_THRESHOLD are defined in the loop block.""" import inspect - from modules.workflows.automation2.executionEngine import executeGraph + from modules.workflowAutomation.engine.executionEngine import executeGraph source = inspect.getsource(executeGraph) assert "STEPLOG_BATCH_THRESHOLD" in source assert "AGGREGATE_FLUSH_THRESHOLD" in source @@ -24,7 +24,7 @@ def test_executionEngine_has_batch_threshold(): def test_executionEngine_has_loop_progress_event(): """Verify loop_progress SSE event is emitted for batch-mode loops.""" import inspect - from modules.workflows.automation2.executionEngine import executeGraph + from modules.workflowAutomation.engine.executionEngine import executeGraph source = inspect.getsource(executeGraph) assert "loop_progress" in source @@ -32,7 +32,7 @@ def test_executionEngine_has_loop_progress_event(): def test_executionEngine_has_concurrency_semaphore(): """Verify asyncio.Semaphore is used for concurrent loop execution.""" import inspect - from modules.workflows.automation2.executionEngine import executeGraph + from modules.workflowAutomation.engine.executionEngine import executeGraph source = inspect.getsource(executeGraph) assert "Semaphore" in source @@ -40,6 +40,6 @@ def test_executionEngine_has_concurrency_semaphore(): def test_executionEngine_aggregate_temp_chunks(): """Verify streaming aggregate flush uses _aggregateTempChunks.""" import inspect - from modules.workflows.automation2.executionEngine import executeGraph + from modules.workflowAutomation.engine.executionEngine import executeGraph source = inspect.getsource(executeGraph) assert "_aggregateTempChunks" in source diff --git a/tests/unit/workflow/test_switch_filtered_output.py b/tests/unit/workflow/test_switch_filtered_output.py index 1cfac160..334a8e81 100644 --- a/tests/unit/workflow/test_switch_filtered_output.py +++ b/tests/unit/workflow/test_switch_filtered_output.py @@ -3,16 +3,16 @@ import pytest -from modules.features.graphicalEditor.portTypes import unwrapTransit, wrapTransit -from modules.features.graphicalEditor.switchOutput import ( +from modules.workflowAutomation.editor.portTypes import unwrapTransit, wrapTransit +from modules.workflowAutomation.editor.switchOutput import ( build_switch_branch_payload, build_switch_combined_output, build_switch_default_payload, unwrap_transit_for_port, ) -from modules.workflows.automation2.executionEngine import _is_node_on_active_path -from modules.workflows.automation2.executors.flowExecutor import FlowExecutor -from modules.workflows.automation2.graphUtils import resolveParameterReferences +from modules.workflowAutomation.engine.executionEngine import _is_node_on_active_path +from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor +from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences from modules.workflows.methods.methodContext.actions.extractContent import PRESENTATION_KIND diff --git a/tests/unit/workflow/test_workflowFileSchema.py b/tests/unit/workflow/test_workflowFileSchema.py index 81849d06..e7109cbc 100644 --- a/tests/unit/workflow/test_workflowFileSchema.py +++ b/tests/unit/workflow/test_workflowFileSchema.py @@ -4,7 +4,7 @@ import pytest -from modules.features.graphicalEditor._workflowFileSchema import ( +from modules.workflowAutomation.editor._workflowFileSchema import ( WORKFLOW_FILE_KIND, WORKFLOW_FILE_SCHEMA_VERSION, WorkflowFileSchemaError, diff --git a/tests/unit/workflows/test_automation2_graphUtils.py b/tests/unit/workflows/test_automation2_graphUtils.py index f76b9545..0ee29412 100644 --- a/tests/unit/workflows/test_automation2_graphUtils.py +++ b/tests/unit/workflows/test_automation2_graphUtils.py @@ -5,7 +5,7 @@ Unit tests for automation2 graphUtils - resolveParameterReferences (ref/value fo import pytest -from modules.workflows.automation2.graphUtils import resolveParameterReferences, validateGraph +from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences, validateGraph _KNOWN_TYPES = frozenset({"trigger.manual", "trigger.form", "ai.prompt", "flow.pass"}) @@ -38,7 +38,7 @@ class TestValidateGraphStartNode: def test_switch_second_output_to_ai_prompt_ok(self): - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES node_type_ids = {n["id"] for n in STATIC_NODE_TYPES} graph = { @@ -220,17 +220,17 @@ class TestPathContainsWildcard: """ def test_detects_wildcard(self): - from modules.workflows.automation2.graphUtils import _pathContainsWildcard + from modules.workflowAutomation.engine.graphUtils import _pathContainsWildcard assert _pathContainsWildcard(["docs", "*", "name"]) is True assert _pathContainsWildcard(["*"]) is True def test_no_wildcard(self): - from modules.workflows.automation2.graphUtils import _pathContainsWildcard + from modules.workflowAutomation.engine.graphUtils import _pathContainsWildcard assert _pathContainsWildcard(["docs", 0, "name"]) is False assert _pathContainsWildcard([]) is False def test_literal_star_in_int_segment_does_not_match(self): - from modules.workflows.automation2.graphUtils import _pathContainsWildcard + from modules.workflowAutomation.engine.graphUtils import _pathContainsWildcard assert _pathContainsWildcard([1, 2, 3]) is False @@ -238,7 +238,7 @@ class TestLoopBodyAndDoneReachability: """flow.loop: body only from output 0; done branch from output 1 (engine helpers).""" def test_body_only_output_0_not_done_chain(self): - from modules.workflows.automation2.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds + from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds conns = [ {"source": "tr", "target": "loop", "targetInput": 0}, @@ -251,7 +251,7 @@ class TestLoopBodyAndDoneReachability: assert getLoopDoneNodeIds("loop", cm) == {"d"} def test_primary_input_prefers_outside_body(self): - from modules.workflows.automation2.graphUtils import ( + from modules.workflowAutomation.engine.graphUtils import ( buildConnectionMap, getLoopBodyNodeIds, getLoopPrimaryInputSource, diff --git a/tests/unit/workflows/test_featureInstanceRefMigration.py b/tests/unit/workflows/test_featureInstanceRefMigration.py index 573f7b66..2ffb6682 100644 --- a/tests/unit/workflows/test_featureInstanceRefMigration.py +++ b/tests/unit/workflows/test_featureInstanceRefMigration.py @@ -11,10 +11,10 @@ import copy import pytest -from modules.workflows.automation2.featureInstanceRefMigration import ( +from modules.workflowAutomation.engine.featureInstanceRefMigration import ( materializeFeatureInstanceRefs, ) -from modules.workflows.automation2.graphUtils import ( +from modules.workflowAutomation.engine.graphUtils import ( _isTypedRefEnvelope, _unwrapTypedRef, resolveParameterReferences, diff --git a/tests/unit/workflows/test_trigger_executor.py b/tests/unit/workflows/test_trigger_executor.py index 446d92da..96a0bf68 100644 --- a/tests/unit/workflows/test_trigger_executor.py +++ b/tests/unit/workflows/test_trigger_executor.py @@ -3,8 +3,8 @@ import pytest -from modules.workflows.automation2.executors.triggerExecutor import TriggerExecutor -from modules.workflows.automation2.runEnvelope import default_run_envelope +from modules.workflowAutomation.engine.executors.triggerExecutor import TriggerExecutor +from modules.workflowAutomation.engine.runEnvelope import default_run_envelope @pytest.mark.asyncio