From 9be2d8aab59fb850b4488894b401883e538df078 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 8 Jun 2026 10:31:17 +0200
Subject: [PATCH] refactory workflowAutomation completed as system component
reolacing automation2 and graphEditor
---
app.py | 18 +-
modules/datamodels/datamodelChat.py | 2 +-
modules/datamodels/datamodelNavigation.py | 8 -
modules/demoConfigs/investorDemo2026.py | 18 +-
modules/demoConfigs/pwgDemo2026.py | 37 +-
modules/features/graphicalEditor/__init__.py | 2 -
.../datamodelFeatureGraphicalEditor.py | 25 -
.../routeFeatureGraphicalEditor.py | 1880 -----------------
modules/interfaces/interfaceBootstrap.py | 9 +-
modules/interfaces/interfaceDbApp.py | 7 +
modules/interfaces/interfaceDbChat.py | 2 +-
modules/interfaces/interfaceDbManagement.py | 2 +-
modules/interfaces/interfaceFeatures.py | 10 +-
modules/interfaces/interfaceRbac.py | 18 +-
.../interfaceWorkflowAutomation.py} | 72 +-
modules/routes/routeAdminFeatures.py | 4 +-
modules/routes/routeAutomationWorkspace.py | 309 ---
modules/routes/routeSystem.py | 2 +-
modules/routes/routeWorkflowAutomation.py | 1780 ++++++++++++++--
modules/routes/routeWorkflowDashboard.py | 1293 ------------
.../services/serviceAgent/datamodelAgent.py | 2 +-
.../services/serviceAgent/toolboxRegistry.py | 2 +-
.../services/serviceAgent/workflowTools.py | 19 +-
...ubscriptionWorkflowAutomationRunFailed.py} | 6 +-
modules/shared/workflowAutomationHelpers.py | 624 ++++++
modules/system/i18nBootSync.py | 10 +-
modules/system/mainSystem.py | 4 +-
modules/workflowAutomation/__init__.py | 8 +
modules/workflowAutomation/editor/__init__.py | 5 +
.../editor}/_workflowFileSchema.py | 4 +-
.../editor}/adapterValidator.py | 2 +-
.../editor}/conditionOperators.py | 4 +-
.../editor}/entryPoints.py | 2 +-
.../editor}/nodeAdapter.py | 0
.../editor}/nodeDefinitions/__init__.py | 0
.../editor}/nodeDefinitions/ai.py | 4 +-
.../editor}/nodeDefinitions/clickup.py | 2 +-
.../editor}/nodeDefinitions/context.py | 16 +-
.../nodeDefinitions/contextPickerHelp.py | 8 +-
.../editor}/nodeDefinitions/data.py | 2 +-
.../editor}/nodeDefinitions/email.py | 4 +-
.../editor}/nodeDefinitions/file.py | 7 +-
.../editor}/nodeDefinitions/flow.py | 16 +-
.../editor}/nodeDefinitions/input.py | 2 +-
.../editor}/nodeDefinitions/redmine.py | 2 +-
.../editor}/nodeDefinitions/sharepoint.py | 2 +-
.../editor}/nodeDefinitions/triggers.py | 2 +-
.../editor}/nodeDefinitions/trustee.py | 8 +-
.../editor}/nodeRegistry.py | 14 +-
.../editor}/portTypes.py | 2 +-
.../editor}/switchOutput.py | 2 +-
.../editor}/upstreamPathsService.py | 8 +-
modules/workflowAutomation/engine/__init__.py | 2 +
.../engine}/clickupTaskUpdateMerge.py | 0
.../engine}/executionEngine.py | 36 +-
.../engine/executors/__init__.py | 18 +
.../engine}/executors/actionNodeExecutor.py | 22 +-
.../engine}/executors/dataExecutor.py | 2 +-
.../engine}/executors/flowExecutor.py | 14 +-
.../engine}/executors/inputExecutor.py | 2 +-
.../engine}/executors/ioExecutor.py | 2 +-
.../engine}/executors/triggerExecutor.py | 2 +-
.../engine}/featureInstanceRefMigration.py | 0
.../engine}/graphUtils.py | 16 +-
.../engine}/pickNotPushMigration.py | 6 +-
.../engine}/runEnvelope.py | 0
.../engine/runFileLogger.py} | 8 +-
.../engine}/scheduleCron.py | 0
.../engine}/udmUpstreamShapes.py | 0
.../engine}/workflowArtifactVisibility.py | 0
.../mainWorkflowAutomation.py} | 203 +-
.../workflowAutomation/scheduler/__init__.py | 11 +
.../scheduler}/emailPoller.py | 12 +-
.../scheduler/mainScheduler.py | 32 +-
modules/workflows/automation2/__init__.py | 13 +-
.../automation2/executors/__init__.py | 17 +-
.../methods/_actionSignatureValidator.py | 2 +-
modules/workflows/methods/methodBase.py | 2 +-
.../methodContext/actions/setContext.py | 2 +-
.../processing/shared/parameterValidation.py | 2 +-
modules/workflows/scheduler/__init__.py | 11 +-
tests/demo/test_demo_bootstrap.py | 4 +-
tests/demo/test_demo_uc1_trustee.py | 2 +-
tests/demo/test_pwg_demo_bootstrap.py | 17 +-
.../test_pick_not_push_migration_v2.py | 6 +-
.../trustee/test_spesenbelege_workflow_e2e.py | 4 +-
...xecute_graph_loop_aggregate_consolidate.py | 8 +-
.../test_action_node_connection_provenance.py | 2 +-
.../graphicalEditor/test_adapter_validator.py | 6 +-
.../test_condition_operator_catalog.py | 2 +-
...est_featureInstanceRef_node_definitions.py | 4 +-
.../unit/graphicalEditor/test_node_adapter.py | 2 +-
.../graphicalEditor/test_portTypes_catalog.py | 2 +-
.../test_port_schema_recursive.py | 2 +-
.../test_resolve_value_kind.py | 2 +-
.../test_route_options_feature_instance.py | 66 -
.../test_upstream_paths_and_graph_schema.py | 6 +-
.../test_trustee_schema_compliance.py | 8 +-
.../unit/nodeDefinitions/test_usesai_flag.py | 2 +-
.../serviceAgent/test_workflow_tools_crud.py | 2 +-
.../workflow/test_extract_content_handover.py | 2 +-
.../workflow/test_flow_executor_conditions.py | 2 +-
tests/unit/workflow/test_node_combinations.py | 18 +-
.../unit/workflow/test_phase3_context_node.py | 10 +-
.../workflow/test_phase4_workflow_nodes.py | 22 +-
tests/unit/workflow/test_phase5_highvol.py | 10 +-
.../workflow/test_switch_filtered_output.py | 10 +-
.../unit/workflow/test_workflowFileSchema.py | 2 +-
.../workflows/test_automation2_graphUtils.py | 14 +-
.../test_featureInstanceRefMigration.py | 4 +-
tests/unit/workflows/test_trigger_executor.py | 4 +-
111 files changed, 2726 insertions(+), 4247 deletions(-)
delete mode 100644 modules/features/graphicalEditor/__init__.py
delete mode 100644 modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py
delete mode 100644 modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
rename modules/{features/graphicalEditor/interfaceFeatureGraphicalEditor.py => interfaces/interfaceWorkflowAutomation.py} (91%)
delete mode 100644 modules/routes/routeAutomationWorkspace.py
delete mode 100644 modules/routes/routeWorkflowDashboard.py
rename modules/serviceCenter/services/serviceMessaging/subscriptions/{subSubscriptionGraphicalEditorRunFailed.py => subSubscriptionWorkflowAutomationRunFailed.py} (91%)
create mode 100644 modules/shared/workflowAutomationHelpers.py
create mode 100644 modules/workflowAutomation/__init__.py
create mode 100644 modules/workflowAutomation/editor/__init__.py
rename modules/{features/graphicalEditor => workflowAutomation/editor}/_workflowFileSchema.py (98%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/adapterValidator.py (99%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/conditionOperators.py (99%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/entryPoints.py (98%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeAdapter.py (100%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/__init__.py (100%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/ai.py (99%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/clickup.py (99%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/context.py (93%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/contextPickerHelp.py (78%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/data.py (97%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/email.py (96%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/file.py (86%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/flow.py (93%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/input.py (98%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/redmine.py (98%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/sharepoint.py (99%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/triggers.py (96%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/trustee.py (93%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeRegistry.py (92%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/portTypes.py (99%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/switchOutput.py (99%)
rename modules/{features/graphicalEditor => workflowAutomation/editor}/upstreamPathsService.py (95%)
create mode 100644 modules/workflowAutomation/engine/__init__.py
rename modules/{workflows/automation2 => workflowAutomation/engine}/clickupTaskUpdateMerge.py (100%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/executionEngine.py (98%)
create mode 100644 modules/workflowAutomation/engine/executors/__init__.py
rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/actionNodeExecutor.py (97%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/dataExecutor.py (99%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/flowExecutor.py (96%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/inputExecutor.py (95%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/ioExecutor.py (95%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/triggerExecutor.py (94%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/featureInstanceRefMigration.py (100%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/graphUtils.py (97%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/pickNotPushMigration.py (97%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/runEnvelope.py (100%)
rename modules/{workflows/automation2/graphicalEditorRunFileLogger.py => workflowAutomation/engine/runFileLogger.py} (97%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/scheduleCron.py (100%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/udmUpstreamShapes.py (100%)
rename modules/{workflows/automation2 => workflowAutomation/engine}/workflowArtifactVisibility.py (100%)
rename modules/{features/graphicalEditor/mainGraphicalEditor.py => workflowAutomation/mainWorkflowAutomation.py} (72%)
create mode 100644 modules/workflowAutomation/scheduler/__init__.py
rename modules/{features/graphicalEditor => workflowAutomation/scheduler}/emailPoller.py (94%)
rename modules/{workflows => workflowAutomation}/scheduler/mainScheduler.py (91%)
delete mode 100644 tests/unit/graphicalEditor/test_route_options_feature_instance.py
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