From bc7c6fe27cb7f529cea699ab7ec878dfd1b59ed2 Mon Sep 17 00:00:00 2001 From: ValueOn AG
[^`]+)`' # inline code
- r'|\*\*(?P.+?)\*\*' # bold
- r'|(?.+?)\*(?!\w)' # italic *x*
- r'|(?.+?)_(?!\w)' # italic _x_
- )
-
- runs = []
- lastEnd = 0
-
- for m in _TOKEN_RE.finditer(text):
- # Plain text before this match
- if m.start() > lastEnd:
- runs.append({"type": "text", "value": text[lastEnd:m.start()]})
-
- if m.group("imgAlt") is not None or m.group("imgSrc") is not None:
- alt = (m.group("imgAlt") or "").strip() or "Image"
- src = (m.group("imgSrc") or "").strip()
- widthStr = m.group("imgWidth")
- run = {"type": "image", "value": alt}
- if src.startswith("file:"):
- run["fileId"] = src[5:]
- else:
- run["href"] = src
- if widthStr:
- run["widthPt"] = int(widthStr)
- runs.append(run)
- elif m.group("linkText") is not None:
- runs.append({"type": "link", "value": m.group("linkText"), "href": m.group("linkHref")})
- elif m.group("code") is not None:
- runs.append({"type": "code", "value": m.group("code")})
- elif m.group("bold") is not None:
- runs.append({"type": "bold", "value": m.group("bold")})
- elif m.group("italic1") is not None:
- runs.append({"type": "italic", "value": m.group("italic1")})
- elif m.group("italic2") is not None:
- runs.append({"type": "italic", "value": m.group("italic2")})
-
- lastEnd = m.end()
-
- # Trailing plain text
- if lastEnd < len(text):
- runs.append({"type": "text", "value": text[lastEnd:]})
-
- return runs if runs else [{"type": "text", "value": text}]
+from modules.shared.documentUtils import parseInlineRuns # noqa: F401 — canonical source in shared/
def _imageRefToDict(token: str) -> Optional[Dict[str, Any]]:
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index 1eaebf56..71dc4526 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -1039,7 +1039,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
# Exception Classes (defined in shared, re-exported here for backward compat)
# ============================================================================
-from modules.shared.serviceExceptions import (
+from modules.datamodels.serviceExceptions import (
SubscriptionInactiveException,
SubscriptionCapacityException,
SUBSCRIPTION_USER_ACTION_UPGRADE,
diff --git a/modules/serviceHub/__init__.py b/modules/serviceHub/__init__.py
index a42f8d0e..14021394 100644
--- a/modules/serviceHub/__init__.py
+++ b/modules/serviceHub/__init__.py
@@ -1,189 +1,7 @@
-# Copyright (c) 2025 Patrick Motsch
-# All rights reserved.
-"""
-Service Hub.
-Consumer-facing aggregation layer for services, DB interfaces, and runtime state.
-
-Architecture:
-- serviceHub delegates service resolution to serviceCenter (DI container)
-- serviceHub owns DB interface initialization and runtime state
-- serviceCenter knows nothing about serviceHub (one-way dependency)
-
-Import-Regelwerk:
-- Zentrale Module (wie dieses) duerfen KEINE Feature-Container importieren
-- Feature-spezifische Services werden dynamisch geladen
-- Shared Services werden via serviceCenter resolved
-"""
-
-import os
-import importlib
-import glob
-from typing import Any, Optional, TYPE_CHECKING
-import logging
-
-from modules.datamodels.datamodelUam import User
-
-if TYPE_CHECKING:
- from modules.datamodels.datamodelChat import ChatWorkflow
-
-logger = logging.getLogger(__name__)
-
-_FEATURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "features")
-
-
-class PublicService:
- """Lightweight proxy exposing only public callable attributes of a target."""
-
- def __init__(self, target: Any, functionsOnly: bool = True, nameFilter=None):
- self._target = target
- self._functionsOnly = functionsOnly
- self._nameFilter = nameFilter
-
- def __getattr__(self, name: str):
- if name.startswith('_'):
- raise AttributeError(f"'{type(self._target).__name__}' attribute '{name}' is private")
- if self._nameFilter and not self._nameFilter(name):
- raise AttributeError(f"'{name}' not exposed by policy")
- attr = getattr(self._target, name)
- if self._functionsOnly and not callable(attr):
- raise AttributeError(f"'{name}' is not a function")
- return attr
-
- def __dir__(self):
- return sorted([
- n for n in dir(self._target)
- if not n.startswith('_')
- and (not self._functionsOnly or callable(getattr(self._target, n, None)))
- and (self._nameFilter(n) if self._nameFilter else True)
- ])
-
-
-class ServiceHub:
- """
- Consumer-facing aggregation of services, DB interfaces, and runtime state.
-
- Services are lazy-resolved via serviceCenter on first access.
- DB interfaces and runtime state are initialized eagerly.
- Feature services/interfaces are discovered dynamically from features/.
- """
-
- _SERVICE_CENTER_WRAPPING = {
- "ai": {"functionsOnly": False},
- }
-
- def __init__(self, user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
- self.user: User = user
- self.workflow = workflow
- self.mandateId: Optional[str] = mandateId
- self.featureInstanceId: Optional[str] = featureInstanceId
- self.currentUserPrompt: str = ""
- self.rawUserPrompt: str = ""
-
- from modules.serviceCenter.context import ServiceCenterContext
- self._serviceCenterContext = ServiceCenterContext(
- user=user,
- workflow=workflow,
- mandate_id=mandateId,
- feature_instance_id=featureInstanceId,
- )
-
- from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
- self.interfaceDbApp = getAppInterface(user, mandateId=mandateId)
-
- from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
- self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId)
-
- self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None
-
- from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
- self.interfaceDbChat = getChatInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId)
-
- self._loadFeatureInterfaces()
- self._loadFeatureServices()
-
- def __getattr__(self, name: str):
- """Lazy-resolve services via serviceCenter on first access."""
- if name.startswith('_'):
- raise AttributeError(name)
- try:
- from modules.serviceCenter import getService
- service = getService(name, self._serviceCenterContext)
- wrapping = self._SERVICE_CENTER_WRAPPING.get(name, {})
- functionsOnly = wrapping.get("functionsOnly", True)
- wrapped = PublicService(service, functionsOnly=functionsOnly)
- setattr(self, name, wrapped)
- return wrapped
- except KeyError:
- raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
-
- def _loadFeatureInterfaces(self):
- """Dynamically load interfaces from feature containers by filename pattern."""
- pattern = os.path.join(_FEATURES_DIR, "*", "interfaceFeature*.py")
- for filepath in glob.glob(pattern):
- try:
- featureDir = os.path.basename(os.path.dirname(filepath))
- filename = os.path.basename(filepath)[:-3]
-
- modulePath = f"modules.features.{featureDir}.{filename}"
- module = importlib.import_module(modulePath)
-
- if hasattr(module, "getInterface"):
- interface = module.getInterface(self.user, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
- attrName = filename.replace("interfaceFeature", "interfaceDb")
- setattr(self, attrName, interface)
- logger.debug(f"Loaded interface: {attrName} from {modulePath}")
- except Exception as e:
- logger.debug(f"Could not load interface from {filepath}: {e}")
-
- def _loadFeatureServices(self):
- """Dynamically load services from feature containers by filename pattern."""
- pattern = os.path.join(_FEATURES_DIR, "*", "service*", "mainService*.py")
- for filepath in glob.glob(pattern):
- try:
- serviceDir = os.path.basename(os.path.dirname(filepath))
- featureDir = os.path.basename(os.path.dirname(os.path.dirname(filepath)))
- filename = os.path.basename(filepath)[:-3]
-
- modulePath = f"modules.features.{featureDir}.{serviceDir}.{filename}"
- module = importlib.import_module(modulePath)
-
- serviceClass = None
- for attrName in dir(module):
- if attrName.endswith("Service") and not attrName.startswith("_"):
- cls = getattr(module, attrName)
- if isinstance(cls, type):
- serviceClass = cls
- break
-
- if serviceClass:
- attrName = serviceDir.replace("service", "").lower()
- if not attrName:
- attrName = serviceDir.lower()
-
- functionsOnly = attrName != "ai"
-
- def _makeServiceResolver(hub):
- def _resolver(depKey: str):
- return getattr(hub, depKey)
- return _resolver
-
- import inspect
- sig = inspect.signature(serviceClass.__init__)
- paramCount = len([p for p in sig.parameters if p != 'self'])
- if paramCount >= 2:
- serviceInstance = serviceClass(self, _makeServiceResolver(self))
- else:
- serviceInstance = serviceClass(self)
- setattr(self, attrName, PublicService(serviceInstance, functionsOnly=functionsOnly))
- logger.debug(f"Loaded service: {attrName} from {modulePath}")
- except Exception as e:
- logger.debug(f"Could not load service from {filepath}: {e}")
-
-
-# Backward-compatible alias
-Services = ServiceHub
-
-
-def getInterface(user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ServiceHub:
- """Get ServiceHub instance for the given user, mandate, and feature instance context."""
- return ServiceHub(user, workflow, mandateId=mandateId, featureInstanceId=featureInstanceId)
+# Re-export shim — canonical source: modules.serviceCenter.serviceHub
+from modules.serviceCenter.serviceHub import ( # noqa: F401
+ PublicService,
+ ServiceHub,
+ Services,
+ getInterface,
+)
diff --git a/modules/shared/documentUtils.py b/modules/shared/documentUtils.py
new file mode 100644
index 00000000..cc08835c
--- /dev/null
+++ b/modules/shared/documentUtils.py
@@ -0,0 +1,64 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""
+Document utility functions (Layer L0 - shared).
+Pure text-processing helpers with zero internal dependencies.
+"""
+
+import re
+
+
+def parseInlineRuns(text: str) -> list:
+ """
+ Parse inline markdown formatting into a list of InlineRun dicts.
+ Handles: images, links, bold, italic, inline code, plain text.
+ Uses a regex-based tokenizer that processes tokens left-to-right.
+ """
+ if not text:
+ return [{"type": "text", "value": ""}]
+
+ _TOKEN_RE = re.compile(
+ r'!\[(?P[^\]]*)\]\((?P[^)"]+)(?:\s+"(?P\d+)pt")?\)'
+ r'|\[(?P[^\]]+)\]\((?P[^)]+)\)'
+ r'|`(?P[^`]+)`'
+ r'|\*\*(?P.+?)\*\*'
+ r'|(?.+?)\*(?!\w)'
+ r'|(?.+?)_(?!\w)'
+ )
+
+ runs = []
+ lastEnd = 0
+
+ for m in _TOKEN_RE.finditer(text):
+ if m.start() > lastEnd:
+ runs.append({"type": "text", "value": text[lastEnd:m.start()]})
+
+ if m.group("imgAlt") is not None or m.group("imgSrc") is not None:
+ alt = (m.group("imgAlt") or "").strip() or "Image"
+ src = (m.group("imgSrc") or "").strip()
+ widthStr = m.group("imgWidth")
+ run = {"type": "image", "value": alt}
+ if src.startswith("file:"):
+ run["fileId"] = src[5:]
+ else:
+ run["href"] = src
+ if widthStr:
+ run["widthPt"] = int(widthStr)
+ runs.append(run)
+ elif m.group("linkText") is not None:
+ runs.append({"type": "link", "value": m.group("linkText"), "href": m.group("linkHref")})
+ elif m.group("code") is not None:
+ runs.append({"type": "code", "value": m.group("code")})
+ elif m.group("bold") is not None:
+ runs.append({"type": "bold", "value": m.group("bold")})
+ elif m.group("italic1") is not None:
+ runs.append({"type": "italic", "value": m.group("italic1")})
+ elif m.group("italic2") is not None:
+ runs.append({"type": "italic", "value": m.group("italic2")})
+
+ lastEnd = m.end()
+
+ if lastEnd < len(text):
+ runs.append({"type": "text", "value": text[lastEnd:]})
+
+ return runs if runs else [{"type": "text", "value": text}]
diff --git a/modules/shared/eventManager.py b/modules/shared/eventManager.py
new file mode 100644
index 00000000..13b0b322
--- /dev/null
+++ b/modules/shared/eventManager.py
@@ -0,0 +1,167 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""
+Event manager for SSE streaming (Layer L0 - shared).
+Manages event queues for Server-Sent Events (SSE) streaming across features.
+Generic pub/sub infrastructure with zero internal dependencies.
+"""
+
+import logging
+import asyncio
+from typing import Dict, Optional, Any
+
+logger = logging.getLogger(__name__)
+
+
+class EventManager:
+ """
+ Manages event queues for SSE streaming.
+ Each workflow has its own async queue for events.
+ """
+
+ def __init__(self):
+ """Initialize the event manager."""
+ self._queues: Dict[str, asyncio.Queue] = {}
+ self._cleanup_tasks: Dict[str, asyncio.Task] = {}
+ self._agent_tasks: Dict[str, asyncio.Task] = {}
+ self._cancelled: Dict[str, bool] = {}
+
+ def create_queue(self, workflow_id: str) -> asyncio.Queue:
+ """Create an event queue for a workflow."""
+ if workflow_id in self._cleanup_tasks:
+ self._cleanup_tasks[workflow_id].cancel()
+ del self._cleanup_tasks[workflow_id]
+ logger.debug(f"Cancelled pending cleanup for workflow {workflow_id}")
+
+ if workflow_id not in self._queues:
+ self._queues[workflow_id] = asyncio.Queue()
+ logger.debug(f"Created event queue for workflow {workflow_id}")
+ else:
+ old = self._queues[workflow_id]
+ while not old.empty():
+ try:
+ old.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+ logger.debug(f"Reusing event queue for workflow {workflow_id} (drained stale events)")
+ return self._queues[workflow_id]
+
+ def get_queue(self, workflow_id: str) -> Optional[asyncio.Queue]:
+ """Get the event queue for a workflow."""
+ return self._queues.get(workflow_id)
+
+ def has_queue(self, workflow_id: str) -> bool:
+ """Check if a queue exists for a workflow."""
+ return workflow_id in self._queues
+
+ def register_agent_task(self, workflow_id: str, task: asyncio.Task) -> None:
+ """Register the asyncio Task running the agent for a workflow."""
+ self._agent_tasks[workflow_id] = task
+ self._cancelled.pop(workflow_id, None)
+
+ def is_cancelled(self, workflow_id: str) -> bool:
+ """Check if a workflow has been cancelled."""
+ return self._cancelled.get(workflow_id, False)
+
+ async def cancel_agent(self, workflow_id: str) -> bool:
+ """Cancel the running agent task for a workflow. Returns True if cancelled."""
+ self._cancelled[workflow_id] = True
+ task = self._agent_tasks.pop(workflow_id, None)
+ if task and not task.done():
+ task.cancel()
+ logger.info(f"Cancelled agent task for workflow {workflow_id}")
+ return True
+ logger.debug(f"No running agent task found for workflow {workflow_id}")
+ return False
+
+ def _unregister_agent_task(self, workflow_id: str) -> None:
+ """Remove the agent task reference after completion."""
+ self._agent_tasks.pop(workflow_id, None)
+ self._cancelled.pop(workflow_id, None)
+
+ async def emit_event(
+ self,
+ context_id: str,
+ event_type: str,
+ data: Dict[str, Any],
+ event_category: str = "chat",
+ message: Optional[str] = None,
+ step: Optional[str] = None
+ ) -> None:
+ """Emit an event to the queue for a workflow."""
+ queue = self._queues.get(context_id)
+ if not queue:
+ return
+
+ event = {
+ "type": event_type,
+ "data": data,
+ "category": event_category,
+ "message": message,
+ "step": step
+ }
+
+ try:
+ await queue.put(event)
+ if event_type not in ("chunk",):
+ logger.debug(f"Emitted {event_type} event for workflow {context_id}")
+ except Exception as e:
+ logger.error(f"Error emitting event for workflow {context_id}: {e}", exc_info=True)
+
+ async def cleanup(self, workflow_id: str, delay: float = 60.0) -> None:
+ """Schedule cleanup of a queue after a delay."""
+ if workflow_id in self._cleanup_tasks:
+ self._cleanup_tasks[workflow_id].cancel()
+
+ async def _cleanup():
+ try:
+ await asyncio.sleep(delay)
+ if workflow_id in self._queues:
+ queue = self._queues[workflow_id]
+ while not queue.empty():
+ try:
+ queue.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+ del self._queues[workflow_id]
+ logger.info(f"Cleaned up event queue for workflow {workflow_id}")
+ except asyncio.CancelledError:
+ logger.debug(f"Cleanup cancelled for workflow {workflow_id}")
+ except Exception as e:
+ logger.error(f"Error during cleanup for workflow {workflow_id}: {e}", exc_info=True)
+ finally:
+ if workflow_id in self._cleanup_tasks:
+ del self._cleanup_tasks[workflow_id]
+
+ task = asyncio.create_task(_cleanup())
+ self._cleanup_tasks[workflow_id] = task
+
+ def shutdown(self) -> None:
+ """Cancel all pending cleanup and agent tasks for fast process exit."""
+ for _wfId, q in list(self._queues.items()):
+ try:
+ q.put_nowait(None)
+ except Exception:
+ pass
+ for wfId, task in list(self._cleanup_tasks.items()):
+ if not task.done():
+ task.cancel()
+ self._cleanup_tasks.clear()
+ for wfId, task in list(self._agent_tasks.items()):
+ if not task.done():
+ task.cancel()
+ self._agent_tasks.clear()
+ self._queues.clear()
+ logger.info("EventManager shutdown: all tasks cancelled, queues drained")
+
+
+# Global event manager instance
+_event_manager: Optional[EventManager] = None
+
+
+def get_event_manager() -> EventManager:
+ """Get the global event manager instance."""
+ global _event_manager
+ if _event_manager is None:
+ _event_manager = EventManager()
+ return _event_manager
diff --git a/modules/shared/featureDiscovery.py b/modules/shared/featureDiscovery.py
new file mode 100644
index 00000000..0332e9c1
--- /dev/null
+++ b/modules/shared/featureDiscovery.py
@@ -0,0 +1,59 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""
+Feature discovery utility (Layer L0 - shared).
+Dynamically discovers and loads feature main modules from the features directory.
+Zero internal dependencies — only os, glob, importlib, logging.
+"""
+
+import os
+import glob
+import importlib
+import logging
+from typing import Dict, Any
+
+logger = logging.getLogger(__name__)
+
+FEATURES_DIR = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "features"
+)
+
+_cachedMainModules = None
+
+
+def loadFeatureMainModules() -> Dict[str, Any]:
+ """
+ Dynamically load main modules from all discovered feature containers.
+ Results are cached after the first call.
+ """
+ global _cachedMainModules
+ if _cachedMainModules is not None:
+ return _cachedMainModules
+
+ mainModules = {}
+ pattern = os.path.join(FEATURES_DIR, "*", "main*.py")
+
+ for filepath in glob.glob(pattern):
+ filename = os.path.basename(filepath)
+ if filename == "__init__.py":
+ continue
+
+ featureDir = os.path.basename(os.path.dirname(filepath))
+ if featureDir.startswith("_"):
+ continue
+
+ if featureDir in mainModules:
+ continue
+
+ mainFile = filename[:-3]
+
+ try:
+ modulePath = f"modules.features.{featureDir}.{mainFile}"
+ module = importlib.import_module(modulePath)
+ mainModules[featureDir] = module
+ logger.debug(f"Loaded main module: {featureDir}")
+ except Exception as e:
+ logger.error(f"Failed to load main module from {featureDir}: {e}")
+
+ _cachedMainModules = mainModules
+ return mainModules
diff --git a/modules/system/gdprDeletion.py b/modules/system/gdprDeletion.py
index c6f8d5ca..ab3a6e2b 100644
--- a/modules/system/gdprDeletion.py
+++ b/modules/system/gdprDeletion.py
@@ -561,39 +561,27 @@ def _deleteUserDataFromFeatureDatabases(userId: str, currentUser) -> Dict[str, A
logger.info(f"Found {len(featureCodes)} feature types to process: {featureCodes}")
- # Process each feature type
+ # Process each feature type via lifecycle hooks
+ from modules.shared.featureDiscovery import loadFeatureMainModules
+ featureModules = loadFeatureMainModules()
+
for featureCode in featureCodes:
try:
- dbName = f"poweron_{featureCode}"
-
- # Try to get feature interface
- featureInterface = None
-
- if featureCode == "trustee":
- from modules.features.trustee.interfaceFeatureTrustee import getInterface as getTrusteeInterface
- featureInterface = getTrusteeInterface(currentUser)
- elif featureCode == "realestate":
- from modules.features.realestate.interfaceFeatureRealEstate import getInterface as getRealEstateInterface
- featureInterface = getRealEstateInterface(currentUser)
- elif featureCode == "neutralization":
- from modules.features.neutralization.interfaceFeatureNeutralizer import getInterface as getNeutralizerInterface
- featureInterface = getNeutralizerInterface(currentUser)
- else:
- logger.warning(f"No interface found for feature code: {featureCode}")
+ featureModule = featureModules.get(featureCode)
+ hook = getattr(featureModule, "onUserDelete", None) if featureModule else None
+
+ if hook is None:
+ logger.warning(f"No onUserDelete hook for feature: {featureCode}")
continue
-
- if featureInterface and hasattr(featureInterface, 'db'):
- featureStats = deleteUserDataFromDatabase(
- featureInterface.db,
- userId,
- dbName
- )
+
+ featureStats = hook(userId, currentUser)
+ if featureStats:
stats["databases"].append(featureStats)
- stats["totalTablesProcessed"] += featureStats["tablesProcessed"]
- stats["totalRecordsDeleted"] += featureStats["recordsDeleted"]
- stats["totalRecordsAnonymized"] += featureStats["recordsAnonymized"]
- stats["errors"].extend(featureStats["errors"])
-
+ stats["totalTablesProcessed"] += featureStats.get("tablesProcessed", 0)
+ stats["totalRecordsDeleted"] += featureStats.get("recordsDeleted", 0)
+ stats["totalRecordsAnonymized"] += featureStats.get("recordsAnonymized", 0)
+ stats["errors"].extend(featureStats.get("errors", []))
+
except Exception as featureErr:
errorMsg = f"Error processing feature {featureCode}: {featureErr}"
logger.warning(errorMsg)
diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py
index e60aa4d3..96f1b69d 100644
--- a/modules/system/i18nBootSync.py
+++ b/modules/system/i18nBootSync.py
@@ -202,19 +202,19 @@ def _registerRbacLabels():
logger.info("i18n rbac labels: %d new keys (rbac.* context)", added)
-def _registerServiceCenterLabels():
- """Register service-center category labels and bootstrap role descriptions."""
+def _registerServiceCenterLabels(serviceLabels: list = None):
+ """Register service-center category labels and bootstrap role descriptions.
+
+ serviceLabels is injected by app.py (Composition Root) to avoid
+ system(L4) → serviceCenter(L5) upward import.
+ """
added = 0
- try:
- from modules.serviceCenter.registry import IMPORTABLE_SERVICES
- for svc in IMPORTABLE_SERVICES.values():
- key = _extractRegistrySourceText(svc.get("label"))
- if key and key not in _REGISTRY:
- _REGISTRY[key] = _I18nRegistryEntry(context="service", value="")
- added += 1
- except ImportError:
- pass
+ for label in (serviceLabels or []):
+ key = _extractRegistrySourceText(label)
+ if key and key not in _REGISTRY:
+ _REGISTRY[key] = _I18nRegistryEntry(context="service", value="")
+ added += 1
_bootstrapRoleDescriptions = [
"Administrator - Benutzer und Ressourcen im Mandanten verwalten",
@@ -288,38 +288,28 @@ def _registerNodeLabels():
logger.info("i18n node labels: %d new keys (node.*/port.* context)", added)
-def _registerAccountingConnectorLabels():
- """Register all accounting connector configField labels at boot time."""
+def _registerAccountingConnectorLabels(accountingLabels: list = None):
+ """Register accounting connector configField labels at boot time.
+
+ Args:
+ accountingLabels: List of dicts with keys 'label' and 'connectorType',
+ injected from app.py to avoid features-import.
+ """
+ if not accountingLabels:
+ return
+
added = 0
- try:
- from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry
- except ImportError:
- logger.debug("i18n accounting connectors: registry not importable")
- return
-
- try:
- registry = getAccountingRegistry()
- except Exception as e:
- logger.warning("i18n accounting connectors: registry init failed: %s", e)
- return
-
- for connectorType, connector in (registry._connectors or {}).items():
- try:
- for field in connector.getRequiredConfigFields():
- key = getattr(field, "label", "") or ""
- if not isinstance(key, str) or not key:
- continue
- if key not in _REGISTRY:
- _REGISTRY[key] = _I18nRegistryEntry(
- context=f"connector.accounting.{connectorType}",
- value="",
- )
- added += 1
- except Exception as e:
- logger.warning(
- "i18n accounting connector %s: failed to read fields: %s",
- connectorType, e,
+ for entry in accountingLabels:
+ key = entry.get("label", "")
+ connectorType = entry.get("connectorType", "unknown")
+ if not isinstance(key, str) or not key:
+ continue
+ if key not in _REGISTRY:
+ _REGISTRY[key] = _I18nRegistryEntry(
+ context=f"connector.accounting.{connectorType}",
+ value="",
)
+ added += 1
logger.info("i18n accounting connector labels: %d new keys", added)
@@ -385,16 +375,21 @@ def _registerDatamodelOptionLabels():
# Public boot API (called by app.py)
# ---------------------------------------------------------------------------
-async def syncRegistryToDb():
- """Boot hook: discover all i18n keys and write them into UiLanguageSet(xx)."""
+async def syncRegistryToDb(serviceLabels: list = None, accountingLabels: list = None):
+ """Boot hook: discover all i18n keys and write them into UiLanguageSet(xx).
+
+ Args:
+ serviceLabels: Service label strings injected from app.py (avoids upward import).
+ accountingLabels: Accounting connector field labels injected from app.py.
+ """
_scanRouteApiMsgKeys()
_registerNavLabels()
_registerFeatureUiLabels()
_registerRbacLabels()
- _registerServiceCenterLabels()
+ _registerServiceCenterLabels(serviceLabels)
_registerNodeLabels()
_registerDatamodelOptionLabels()
- _registerAccountingConnectorLabels()
+ _registerAccountingConnectorLabels(accountingLabels)
if not _REGISTRY:
logger.info("i18n registry: no keys to sync (empty registry)")
diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py
index 21d0cbee..bbdffbbd 100644
--- a/modules/system/mainSystem.py
+++ b/modules/system/mainSystem.py
@@ -20,351 +20,7 @@ FEATURE_CODE = "system"
FEATURE_LABEL = "System"
FEATURE_ICON = "mdi-cog"
-# =============================================================================
-# Navigation Structure (Single Source of Truth)
-# =============================================================================
-#
-# Block Order (gemäss Navigation-API-Konzept):
-# - System: 10
-# - : 15 (wird in routeSystem.py eingefügt)
-# - Basisdaten: 30
-# - Administration: 200
-#
-# NOTE: Workflows and Migrate sections removed - now handled as features
-#
-# Item Order: Default-Abstand 10 pro Item
-# uiComponent: Abgeleitet von objectKey (ui.system.home -> page.system.home)
-# icon: Wird intern gehalten aber NICHT in der API Response zurückgegeben
-
-NAVIGATION_SECTIONS = [
- # ─── Meine Sicht (with top-level item + subgroups) ───
- {
- "id": "system",
- "title": t("Meine Sicht"),
- "order": 10,
- "items": [
- {
- "id": "home",
- "objectKey": "ui.system.home",
- "label": t("Start"),
- "icon": "FaHome",
- "path": "/",
- "order": 10,
- "public": True,
- },
- ],
- "subgroups": [
- # ── Übersichten ──
- {
- "id": "system-overviews",
- "title": t("Übersichten"),
- "order": 15,
- "items": [
- {
- "id": "integrations",
- "objectKey": "ui.system.integrations",
- "label": t("Integrationen"),
- "icon": "FaProjectDiagram",
- "path": "/integrations",
- "order": 10,
- "public": True,
- },
- {
- "id": "compliance-audit",
- "objectKey": "ui.system.complianceAudit",
- "label": t("Compliance & Audit"),
- "icon": "FaShieldAlt",
- "path": "/compliance-audit",
- "order": 20,
- },
- ],
- },
- # ── Basisdaten ──
- {
- "id": "system-basedata",
- "title": t("Basisdaten"),
- "order": 20,
- "items": [
- {
- "id": "connections",
- "objectKey": "ui.system.connections",
- "label": t("Verbindungen"),
- "icon": "FaLink",
- "path": "/basedata/connections",
- "order": 10,
- "public": True,
- },
- {
- "id": "files",
- "objectKey": "ui.system.files",
- "label": t("Dateien"),
- "icon": "FaRegFileAlt",
- "path": "/basedata/files",
- "order": 20,
- "public": True,
- },
- {
- "id": "prompts",
- "objectKey": "ui.system.prompts",
- "label": t("Prompts"),
- "icon": "FaLightbulb",
- "path": "/basedata/prompts",
- "order": 30,
- "public": True,
- },
- ],
- },
- # ── Nutzung ──
- {
- "id": "system-usage",
- "title": t("Nutzung"),
- "order": 30,
- "items": [
- {
- "id": "billing-admin",
- "objectKey": "ui.system.billingAdmin",
- "label": t("Abrechnung"),
- "icon": "FaMoneyBillAlt",
- "path": "/billing/admin",
- "order": 10,
- },
- {
- "id": "statistics",
- "objectKey": "ui.system.statistics",
- "label": t("Statistiken"),
- "icon": "FaChartBar",
- "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",
- "label": t("RAG-Inventar"),
- "icon": "FaDatabase",
- "path": "/rag-inventory",
- "order": 35,
- },
- {
- "id": "store",
- "objectKey": "ui.system.store",
- "label": t("Store"),
- "icon": "FaStore",
- "path": "/store",
- "order": 40,
- "public": True,
- },
- {
- "id": "settings",
- "objectKey": "ui.system.settings",
- "label": t("Einstellungen"),
- "icon": "FaCog",
- "path": "/settings",
- "order": 50,
- "public": True,
- },
- ],
- },
- ],
- },
- # ─── Administration (with subgroups) ───
- {
- "id": "admin",
- "title": t("Administration"),
- "order": 200,
- "subgroups": [
- # ── Wizards ──
- {
- "id": "admin-wizards",
- "title": t("Wizards"),
- "order": 10,
- "items": [
- {
- "id": "admin-mandate-wizard",
- "objectKey": "ui.admin.mandateWizard",
- "label": t("Mandanten-Wizard"),
- "icon": "FaMagic",
- "path": "/admin/mandate-wizard",
- "order": 10,
- "adminOnly": True,
- },
- {
- "id": "admin-invitation-wizard",
- "objectKey": "ui.admin.invitationWizard",
- "label": t("Einladungs-Wizard"),
- "icon": "FaEnvelopeOpenText",
- "path": "/admin/invitation-wizard",
- "order": 20,
- "adminOnly": True,
- },
- ],
- },
- # ── Users ──
- {
- "id": "admin-users-group",
- "title": t("Benutzer"),
- "order": 20,
- "items": [
- {
- "id": "admin-users",
- "objectKey": "ui.admin.users",
- "label": t("Benutzer"),
- "icon": "FaUsers",
- "path": "/admin/users",
- "order": 10,
- "adminOnly": True,
- },
- {
- "id": "admin-invitations",
- "objectKey": "ui.admin.invitations",
- "label": t("Benutzer-Einladungen"),
- "icon": "FaEnvelopeOpenText",
- "path": "/admin/invitations",
- "order": 20,
- "adminOnly": True,
- },
- {
- "id": "admin-user-access-overview",
- "objectKey": "ui.admin.userAccessOverview",
- "label": t("Benutzer-Zugriffsübersicht"),
- "icon": "FaClipboardList",
- "path": "/admin/user-access-overview",
- "order": 30,
- "adminOnly": True,
- },
- {
- "id": "admin-subscriptions",
- "objectKey": "ui.admin.subscriptions",
- "label": t("Abonnements"),
- "icon": "FaFileContract",
- "path": "/admin/subscriptions",
- "order": 40,
- "adminOnly": True,
- },
- ],
- },
- # ── System ──
- {
- "id": "admin-system-group",
- "title": t("System"),
- "order": 30,
- "items": [
- {
- "id": "admin-roles",
- "objectKey": "ui.admin.roles",
- "label": t("Rollen"),
- "icon": "FaUserTag",
- "path": "/admin/mandate-roles",
- "order": 10,
- "adminOnly": True,
- },
- {
- "id": "admin-mandate-role-permissions",
- "objectKey": "ui.admin.mandateRolePermissions",
- "label": t("Rollen-Berechtigungen"),
- "icon": "FaKey",
- "path": "/admin/mandate-role-permissions",
- "order": 20,
- "adminOnly": True,
- },
- {
- "id": "admin-mandates",
- "objectKey": "ui.admin.mandates",
- "label": t("Mandanten"),
- "icon": "FaBuilding",
- "path": "/admin/mandates",
- "order": 30,
- "adminOnly": True,
- },
- {
- "id": "admin-user-mandates",
- "objectKey": "ui.admin.userMandates",
- "label": t("Mandanten-Mitglieder"),
- "icon": "FaUserFriends",
- "path": "/admin/user-mandates",
- "order": 40,
- "adminOnly": True,
- },
- {
- "id": "admin-access",
- "objectKey": "ui.admin.access",
- "label": t("Zugriffsverwaltung"),
- "icon": "FaBuilding",
- "path": "/admin/access",
- "order": 50,
- "adminOnly": True,
- },
- {
- "id": "admin-feature-instances",
- "objectKey": "ui.admin.featureInstances",
- "label": t("Feature-Instanzen"),
- "icon": "FaCubes",
- "path": "/admin/feature-instances",
- "order": 60,
- "adminOnly": True,
- },
- {
- "id": "admin-feature-roles",
- "objectKey": "ui.admin.featureRoles",
- "label": t("Features Rollen-Vorlagen"),
- "icon": "FaShieldAlt",
- "path": "/admin/feature-roles",
- "order": 70,
- "adminOnly": True,
- "sysAdminOnly": True,
- },
- {
- "id": "admin-logs",
- "objectKey": "ui.admin.logs",
- "label": t("Logs"),
- "icon": "FaFileAlt",
- "path": "/admin/logs",
- "order": 90,
- "adminOnly": True,
- "sysAdminOnly": True,
- },
- {
- "id": "admin-languages",
- "objectKey": "ui.admin.languages",
- "label": t("UI-Sprachen"),
- "icon": "FaGlobe",
- "path": "/admin/languages",
- "order": 95,
- "adminOnly": True,
- "sysAdminOnly": True,
- },
- {
- "id": "admin-database-health",
- "objectKey": "ui.admin.databaseHealth",
- "label": t("Datenbank-Gesundheit"),
- "icon": "FaDatabase",
- "path": "/admin/database-health",
- "order": 98,
- "adminOnly": True,
- "sysAdminOnly": True,
- },
- {
- "id": "admin-demo-config",
- "objectKey": "ui.admin.demoConfig",
- "label": t("Demo Config"),
- "icon": "FaCubes",
- "path": "/admin/demo-config",
- "order": 100,
- "adminOnly": True,
- "sysAdminOnly": True,
- },
- ],
- },
- ],
- },
-]
+from modules.datamodels.datamodelNavigation import NAVIGATION_SECTIONS # noqa: F401 — canonical source
def objectKeyToUiComponent(objectKey: str) -> str:
diff --git a/modules/system/registry.py b/modules/system/registry.py
index 67f3d28b..1e2dffb4 100644
--- a/modules/system/registry.py
+++ b/modules/system/registry.py
@@ -89,45 +89,7 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]:
return results
-_cachedMainModules = None
-
-def loadFeatureMainModules() -> Dict[str, Any]:
- """
- Dynamically load main modules from all discovered feature containers.
- Results are cached after the first call.
- """
- global _cachedMainModules
- if _cachedMainModules is not None:
- return _cachedMainModules
-
- mainModules = {}
- pattern = os.path.join(FEATURES_DIR, "*", "main*.py")
-
- for filepath in glob.glob(pattern):
- filename = os.path.basename(filepath)
- if filename == "__init__.py":
- continue
-
- featureDir = os.path.basename(os.path.dirname(filepath))
- if featureDir.startswith("_"):
- continue
-
- # Skip if this feature already has a main module loaded (avoid duplicates)
- if featureDir in mainModules:
- continue
-
- mainFile = filename[:-3] # Remove .py
-
- try:
- modulePath = f"modules.features.{featureDir}.{mainFile}"
- module = importlib.import_module(modulePath)
- mainModules[featureDir] = module
- logger.debug(f"Loaded main module: {featureDir}")
- except Exception as e:
- logger.error(f"Failed to load main module from {featureDir}: {e}")
-
- _cachedMainModules = mainModules
- return mainModules
+from modules.shared.featureDiscovery import loadFeatureMainModules # noqa: F401 — re-export
def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]:
@@ -150,16 +112,8 @@ def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]:
logger.error(f"Error registering system RBAC objects: {e}")
results["system"] = False
- # Register service center RBAC objects (service.web, service.extraction, etc.)
- try:
- from modules.serviceCenter import registerServiceObjects
- success = registerServiceObjects(catalogService)
- results["servicecenter"] = success
- except ImportError as e:
- logger.warning(f"Service center not found, skipping service RBAC registration: {e}")
- except Exception as e:
- logger.error(f"Error registering service RBAC objects: {e}")
- results["servicecenter"] = False
+ # Service center RBAC objects are registered by app.py (Composition Root)
+ # to avoid system(L4) → serviceCenter(L5) upward import.
# Register feature modules
mainModules = loadFeatureMainModules()
diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflows/automation2/executionEngine.py
index f8d95f1c..d5fdbd0e 100644
--- a/modules/workflows/automation2/executionEngine.py
+++ b/modules/workflows/automation2/executionEngine.py
@@ -31,7 +31,7 @@ from modules.workflows.automation2.executors import (
)
from modules.features.graphicalEditor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
-from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
+from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
from modules.workflows.automation2.graphicalEditorRunFileLogger import (
GraphicalEditorRunFileLogger,
graphical_editor_run_file_logging_enabled,
@@ -252,7 +252,7 @@ def _merge_node_parameters_into_snap(
def _emitStepEvent(runId: str, stepData: Dict[str, Any]) -> None:
"""Emit a step-log SSE event to any listening client for this run."""
try:
- from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager
+ from modules.shared.eventManager import get_event_manager
em = get_event_manager()
queueId = f"run-trace-{runId}"
if not em.has_queue(queueId):
diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py
index 20fed58a..9af626d4 100644
--- a/modules/workflows/automation2/executors/actionNodeExecutor.py
+++ b/modules/workflows/automation2/executors/actionNodeExecutor.py
@@ -19,7 +19,7 @@ from modules.features.graphicalEditor.portTypes import (
_normalizeError,
normalizeToSchema,
)
-from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
+from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
diff --git a/modules/workflows/methods/methodAi/actions/consolidate.py b/modules/workflows/methods/methodAi/actions/consolidate.py
index 0dced074..70d345cd 100644
--- a/modules/workflows/methods/methodAi/actions/consolidate.py
+++ b/modules/workflows/methods/methodAi/actions/consolidate.py
@@ -7,7 +7,7 @@ from typing import Any, Dict, List
from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum
from modules.datamodels.datamodelChat import ActionResult
-from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError
+from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError
logger = logging.getLogger(__name__)
diff --git a/modules/workflows/methods/methodAi/actions/generateCode.py b/modules/workflows/methods/methodAi/actions/generateCode.py
index 66a1d0bf..7a13e4a1 100644
--- a/modules/workflows/methods/methodAi/actions/generateCode.py
+++ b/modules/workflows/methods/methodAi/actions/generateCode.py
@@ -9,7 +9,7 @@ from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.datamodels.datamodelExtraction import ContentPart
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData
-from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError
+from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError
logger = logging.getLogger(__name__)
diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py
index 2006ba96..20b82042 100644
--- a/modules/workflows/methods/methodAi/actions/generateDocument.py
+++ b/modules/workflows/methods/methodAi/actions/generateDocument.py
@@ -9,7 +9,7 @@ from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.datamodels.datamodelExtraction import ContentPart
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData
-from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError
+from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError
logger = logging.getLogger(__name__)
diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py
index 47774eb1..04046f39 100644
--- a/modules/workflows/methods/methodAi/actions/process.py
+++ b/modules/workflows/methods/methodAi/actions/process.py
@@ -10,7 +10,7 @@ from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
from modules.datamodels.datamodelExtraction import ContentPart
-from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError
+from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError
logger = logging.getLogger(__name__)
diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py
index 0dfdeeab..778faf11 100644
--- a/modules/workflows/methods/methodAi/actions/webResearch.py
+++ b/modules/workflows/methods/methodAi/actions/webResearch.py
@@ -7,8 +7,7 @@ import re
import json
from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
-from modules.serviceCenter import ServiceCenterContext, getService, can_access_service
-from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError
+from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError
logger = logging.getLogger(__name__)
@@ -45,6 +44,8 @@ def _build_research_prompt(parameters: Dict[str, Any]) -> str:
async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
+ from modules.serviceCenter import ServiceCenterContext, getService, can_access_service
+
operationId = None
try:
prompt = _build_research_prompt(parameters)
diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py
index 44309888..2c1a2f9c 100644
--- a/modules/workflows/methods/methodContext/actions/extractContent.py
+++ b/modules/workflows/methods/methodContext/actions/extractContent.py
@@ -1399,7 +1399,7 @@ def _load_image_bytes_by_file_id(services: Any, file_id: str) -> Optional[bytes]
def _inline_runs_from_presentation_lines(lines: List[Any]) -> List[Dict[str, Any]]:
"""Map presentation ``lines`` to inline runs, preserving line order with explicit breaks."""
- from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import parseInlineRuns
+ from modules.shared.documentUtils import parseInlineRuns
runs: List[Dict[str, Any]] = []
first = True
@@ -1537,7 +1537,7 @@ def presentation_envelopes_to_document_json(
services: Any = None,
) -> Dict[str, Any]:
"""Map presentation envelope(s) to ``renderReport`` ``extractedContent`` (documents/sections)."""
- from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import parseInlineRuns
+ from modules.shared.documentUtils import parseInlineRuns
envelopes = normalize_presentation_envelopes(raw)
if not envelopes:
diff --git a/modules/workflows/processing/shared/methodDiscovery.py b/modules/workflows/processing/shared/methodDiscovery.py
index 213dee83..d778ba39 100644
--- a/modules/workflows/processing/shared/methodDiscovery.py
+++ b/modules/workflows/processing/shared/methodDiscovery.py
@@ -34,57 +34,78 @@ def _collectActionsUnfiltered(methodInstance) -> Dict[str, Dict[str, Any]]:
return result
+def _registerMethodClasses(serviceCenter, modulePath: str, uniqueCount: int) -> int:
+ """Import a method module and register all MethodBase subclasses found in it."""
+ try:
+ module = importlib.import_module(modulePath)
+ except Exception as e:
+ logger.error(f"Error importing method module {modulePath}: {e}")
+ return uniqueCount
+
+ for itemName, item in inspect.getmembers(module):
+ if (inspect.isclass(item) and
+ issubclass(item, MethodBase) and
+ item != MethodBase):
+
+ if itemName in methods:
+ continue
+
+ shortName = itemName.replace('Method', '').lower()
+ methodInstance = item(serviceCenter)
+ actions = _collectActionsUnfiltered(methodInstance)
+
+ methodInfo = {
+ 'instance': methodInstance,
+ 'actions': actions,
+ 'description': item.__doc__ or f"Method {itemName}"
+ }
+
+ methods[itemName] = methodInfo
+ methods[shortName] = methodInfo
+ uniqueCount += 1
+ logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions")
+
+ return uniqueCount
+
+
def discoverMethods(serviceCenter):
- """Dynamically discover all method classes and their actions in modules methods package.
-
- Always creates fresh method instances bound to the given serviceCenter,
- preventing stale or cross-workflow service references.
+ """Dynamically discover all method classes and their actions.
+
+ Scans two locations:
+ 1. modules.workflows.methods (core methods)
+ 2. modules.features.*/workflows/ (feature-owned methods)
"""
global methods
try:
methodsPackage = importlib.import_module('modules.workflows.methods')
-
- # Clear and rebuild to prevent cross-workflow state contamination
+
methods.clear()
uniqueCount = 0
-
+
for _, name, isPkg in pkgutil.iter_modules(methodsPackage.__path__):
if name.startswith('method'):
- try:
- module = importlib.import_module(f'modules.workflows.methods.{name}')
-
- for itemName, item in inspect.getmembers(module):
- if (inspect.isclass(item) and
- issubclass(item, MethodBase) and
- item != MethodBase):
-
- shortName = itemName.replace('Method', '').lower()
-
- # Skip if already processed (via another module path)
- if itemName in methods:
- continue
-
- methodInstance = item(serviceCenter)
- actions = _collectActionsUnfiltered(methodInstance)
-
- methodInfo = {
- 'instance': methodInstance,
- 'actions': actions,
- 'description': item.__doc__ or f"Method {itemName}"
- }
-
- methods[itemName] = methodInfo
- methods[shortName] = methodInfo
- uniqueCount += 1
-
- logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions")
-
- except Exception as e:
- logger.error(f"Error discovering method {name}: {str(e)}")
- continue
-
+ uniqueCount = _registerMethodClasses(
+ serviceCenter, f'modules.workflows.methods.{name}', uniqueCount
+ )
+
+ # Feature-owned methods (e.g. features/trustee/workflows/methodTrustee)
+ import os
+ import glob as _glob
+ featuresDir = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
+ "features"
+ )
+ for wfInit in _glob.glob(os.path.join(featuresDir, "*", "workflows", "__init__.py")):
+ wfDir = os.path.dirname(wfInit)
+ featureName = os.path.basename(os.path.dirname(wfDir))
+ for entry in os.listdir(wfDir):
+ entryPath = os.path.join(wfDir, entry)
+ if os.path.isdir(entryPath) and entry.startswith("method"):
+ modulePath = f"modules.features.{featureName}.workflows.{entry}"
+ uniqueCount = _registerMethodClasses(serviceCenter, modulePath, uniqueCount)
+
logger.info(f"Discovered {uniqueCount} unique methods ({len(methods)} entries with aliases)")
-
+
except Exception as e:
logger.error(f"Error discovering methods: {str(e)}")
diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflows/scheduler/mainScheduler.py
index ef89f821..9af9889f 100644
--- a/modules/workflows/scheduler/mainScheduler.py
+++ b/modules/workflows/scheduler/mainScheduler.py
@@ -396,48 +396,29 @@ def _createRunFailedNotification(
logger.warning("Failed to create in-app run.failed notification: %s", e)
+_onRunFailedCallback = None
+
+
+def setOnRunFailedCallback(callback) -> None:
+ """Set the callback for run failure notifications (injected by app.py)."""
+ global _onRunFailedCallback
+ _onRunFailedCallback = callback
+
+
def _triggerRunFailedSubscription(
workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None
) -> None:
- """Trigger the messaging subscription for run failures (email notifications)."""
+ """Trigger the messaging subscription for run failures via injected callback."""
+ if _onRunFailedCallback is None:
+ return
try:
- from modules.serviceCenter import getService
- from modules.serviceCenter.context import ServiceCenterContext
- from modules.interfaces.interfaceDbApp import getRootInterface
- from modules.datamodels.datamodelMessaging import MessagingEventParameters
-
- rootInterface = getRootInterface()
- if not rootInterface:
- return
- eventUser = rootInterface.getUserByUsername("event")
- if not eventUser:
- return
-
- ctx = ServiceCenterContext(
- user=eventUser,
- mandate_id=mandateId or "",
- feature_instance_id="",
- feature_code="graphicalEditor",
+ _onRunFailedCallback(
+ workflowId=workflowId,
+ runId=runId,
+ error=error,
+ mandateId=mandateId,
+ workflowLabel=workflowLabel,
)
- messagingService = getService("messaging", ctx)
-
- subscriptionId = "GraphicalEditorRunFailed"
- eventParams = MessagingEventParameters(triggerData={
- "workflowId": workflowId,
- "workflowLabel": workflowLabel or workflowId,
- "runId": runId,
- "error": error,
- "mandateId": mandateId or "",
- })
- result = messagingService.executeSubscription(subscriptionId, eventParams)
- logger.info(
- "Triggered run.failed subscription: sent=%d success=%s",
- result.messagesSent, result.success,
- )
- except FileNotFoundError:
- logger.debug("Subscription function GraphicalEditorRunFailed not found (not yet registered)")
- except ValueError as e:
- logger.debug("Subscription GraphicalEditorRunFailed: %s", e)
except Exception as e:
logger.warning("Failed to trigger run.failed subscription: %s", e)
From 877f859f6b27eb44b077e3a34540f42929817086 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 7 Jun 2026 08:25:43 +0200
Subject: [PATCH 03/16] fix(tests): align test imports with refactored module
paths
Fix broken test imports after architecture refactoring:
- mfaService: _buildTotp -> buildTotp, _decryptSecret -> decryptSecret
- _actionSignatureValidator: _validateTypeRef -> validateTypeRef
- fkRegistry: modules.shared -> modules.dbHelpers
- costEstimate/ragLimits: _costEstimate -> costEstimate, _ragLimits -> ragLimits
- udbNodes: _isFeatureAdmin -> isFeatureAdmin
- inheritFlags: _normalisePath -> normalisePath
- methodTrustee: old workflow path -> features/trustee/workflows
- methodDiscovery: fix featuresDir path calculation (4 dirname levels)
- mainGraphicalEditor: wrap template labels with t() for i18n
Co-authored-by: Cursor
---
.../graphicalEditor/mainGraphicalEditor.py | 4 ++--
.../processing/shared/methodDiscovery.py | 2 +-
.../trustee/test_spesenbelege_workflow_e2e.py | 2 +-
tests/unit/auth/test_mfaService.py | 20 +++++++++----------
.../graphicalEditor/test_adapter_validator.py | 2 +-
.../test_action_signature_validator.py | 6 +++---
tests/unit/services/test_costEstimate.py | 2 +-
.../services/test_featureDataAgent_schema.py | 2 +-
tests/unit/services/test_inheritFlags.py | 10 +++++-----
tests/unit/services/test_queryValidator.py | 2 +-
tests/unit/services/test_ragLimits.py | 2 +-
tests/unit/services/test_trusteeOntology.py | 2 +-
tests/unit/services/test_udbNodes.py | 10 +++++-----
tests/unit/workflow/test_trusteeQueryData.py | 2 +-
14 files changed, 34 insertions(+), 34 deletions(-)
diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/features/graphicalEditor/mainGraphicalEditor.py
index 44cb890e..bf50abb2 100644
--- a/modules/features/graphicalEditor/mainGraphicalEditor.py
+++ b/modules/features/graphicalEditor/mainGraphicalEditor.py
@@ -413,7 +413,7 @@ def _buildSystemTemplates():
"""Build the graph definitions for platform system templates."""
return [
{
- "label": "Personal Assistant: E-Mail-Antwort-Drafting",
+ "label": t("Personal Assistant: E-Mail-Antwort-Drafting"),
"mandateId": None,
"featureInstanceId": None,
"isTemplate": True,
@@ -442,7 +442,7 @@ def _buildSystemTemplates():
"invocations": [{"type": "schedule", "cronExpression": "0 8 * * 1-5"}],
},
{
- "label": "Treuhand: PDF-Klassifizierung & Trustee-Import",
+ "label": t("Treuhand: PDF-Klassifizierung & Trustee-Import"),
"mandateId": None,
"featureInstanceId": None,
"isTemplate": True,
diff --git a/modules/workflows/processing/shared/methodDiscovery.py b/modules/workflows/processing/shared/methodDiscovery.py
index d778ba39..9271585c 100644
--- a/modules/workflows/processing/shared/methodDiscovery.py
+++ b/modules/workflows/processing/shared/methodDiscovery.py
@@ -92,7 +92,7 @@ def discoverMethods(serviceCenter):
import os
import glob as _glob
featuresDir = os.path.join(
- os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
"features"
)
for wfInit in _glob.glob(os.path.join(featuresDir, "*", "workflows", "__init__.py")):
diff --git a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py
index 171eff4d..fcda01e4 100644
--- a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py
+++ b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py
@@ -178,7 +178,7 @@ def resetAccountingBridgeCalls():
def patchTrustee(monkeypatch, trusteeInterface):
"""Patches ``getInterface`` + ``AccountingBridge`` in both action
modules so the real action code runs against the in-memory fakes."""
- from modules.workflows.methods.methodTrustee.actions import (
+ from modules.features.trustee.workflows.methodTrustee.actions import (
processDocuments as _procMod,
syncToAccounting as _syncMod,
)
diff --git a/tests/unit/auth/test_mfaService.py b/tests/unit/auth/test_mfaService.py
index b6f3a1e3..5010ceef 100644
--- a/tests/unit/auth/test_mfaService.py
+++ b/tests/unit/auth/test_mfaService.py
@@ -13,7 +13,7 @@ import pyotp
from modules.auth.mfaService import (
_generateSecret,
- _buildTotp,
+ buildTotp,
generateSetup,
confirmSetup,
verifyCode,
@@ -28,27 +28,27 @@ class TestTotpBasics:
assert isinstance(secret, str)
assert len(secret) >= 16
- def test_buildTotp_generates_valid_code(self):
+ def testbuildTotp_generates_valid_code(self):
secret = _generateSecret()
- totp = _buildTotp(secret)
+ totp = buildTotp(secret)
code = totp.now()
assert len(code) == 6
assert code.isdigit()
def test_verifyCode_accepts_current_code(self):
secret = _generateSecret()
- totp = _buildTotp(secret)
+ totp = buildTotp(secret)
code = totp.now()
encrypted = f"FAKE_ENC:{secret}"
- with patch("modules.auth.mfaService._decryptSecret", return_value=secret):
+ with patch("modules.auth.mfaService.decryptSecret", return_value=secret):
assert verifyCode(encrypted, code) is True
def test_verifyCode_rejects_wrong_code(self):
secret = _generateSecret()
encrypted = f"FAKE_ENC:{secret}"
- with patch("modules.auth.mfaService._decryptSecret", return_value=secret):
+ with patch("modules.auth.mfaService.decryptSecret", return_value=secret):
assert verifyCode(encrypted, "000000") is False
@@ -66,19 +66,19 @@ class TestGenerateSetup:
class TestConfirmSetup:
def test_confirmSetup_with_valid_code(self):
secret = _generateSecret()
- totp = _buildTotp(secret)
+ totp = buildTotp(secret)
code = totp.now()
- with patch("modules.auth.mfaService._decryptSecret", return_value=secret):
+ with patch("modules.auth.mfaService.decryptSecret", return_value=secret):
assert confirmSetup("ENC", code) is True
def test_confirmSetup_with_invalid_code(self):
secret = _generateSecret()
- with patch("modules.auth.mfaService._decryptSecret", return_value=secret):
+ with patch("modules.auth.mfaService.decryptSecret", return_value=secret):
assert confirmSetup("ENC", "999999") is False
def test_confirmSetup_handles_decryption_error(self):
- with patch("modules.auth.mfaService._decryptSecret", side_effect=Exception("decrypt error")):
+ with patch("modules.auth.mfaService.decryptSecret", side_effect=Exception("decrypt error")):
assert confirmSetup("BAD_ENC", "123456") is False
diff --git a/tests/unit/graphicalEditor/test_adapter_validator.py b/tests/unit/graphicalEditor/test_adapter_validator.py
index 5f8091fd..5ee5abef 100644
--- a/tests/unit/graphicalEditor/test_adapter_validator.py
+++ b/tests/unit/graphicalEditor/test_adapter_validator.py
@@ -253,7 +253,7 @@ def _ensureOptionalDeps():
_LIVE_METHODS = [
- ("modules.workflows.methods.methodTrustee.methodTrustee", "MethodTrustee", "trustee"),
+ ("modules.features.trustee.workflows.methodTrustee.methodTrustee", "MethodTrustee", "trustee"),
("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine", "redmine"),
("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint", "sharepoint"),
("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook", "outlook"),
diff --git a/tests/unit/methods/test_action_signature_validator.py b/tests/unit/methods/test_action_signature_validator.py
index 5607117a..7afd4597 100644
--- a/tests/unit/methods/test_action_signature_validator.py
+++ b/tests/unit/methods/test_action_signature_validator.py
@@ -25,7 +25,7 @@ from modules.workflows.methods._actionSignatureValidator import (
_validateActionParameter,
_validateActionsDict,
_validateMethods,
- _validateTypeRef,
+ validateTypeRef,
)
@@ -75,7 +75,7 @@ class TestValidateTypeRef:
"List[FeatureInstanceRef]",
])
def test_validTypes(self, t):
- assert _validateTypeRef(t) == []
+ assert validateTypeRef(t) == []
@pytest.mark.parametrize("t", [
"list", # too generic
@@ -86,7 +86,7 @@ class TestValidateTypeRef:
"", # empty
])
def test_invalidTypes(self, t):
- errors = _validateTypeRef(t)
+ errors = validateTypeRef(t)
assert errors, f"expected validation errors for {t!r}"
diff --git a/tests/unit/services/test_costEstimate.py b/tests/unit/services/test_costEstimate.py
index 00fbb6b6..a8e25138 100644
--- a/tests/unit/services/test_costEstimate.py
+++ b/tests/unit/services/test_costEstimate.py
@@ -8,7 +8,7 @@ from __future__ import annotations
import unittest
-from modules.serviceCenter.services.serviceKnowledge import _costEstimate
+from modules.serviceCenter.services.serviceKnowledge import costEstimate as _costEstimate
class TestCostEstimate(unittest.TestCase):
diff --git a/tests/unit/services/test_featureDataAgent_schema.py b/tests/unit/services/test_featureDataAgent_schema.py
index 0e852c70..2b70532d 100644
--- a/tests/unit/services/test_featureDataAgent_schema.py
+++ b/tests/unit/services/test_featureDataAgent_schema.py
@@ -24,7 +24,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
-from modules.shared import fkRegistry
+from modules.dbHelpers import fkRegistry
from modules.serviceCenter.services.serviceAgent.datamodelAgent import (
ToolCallRequest, ToolResult,
)
diff --git a/tests/unit/services/test_inheritFlags.py b/tests/unit/services/test_inheritFlags.py
index 3b9ce395..a74f1f7f 100644
--- a/tests/unit/services/test_inheritFlags.py
+++ b/tests/unit/services/test_inheritFlags.py
@@ -626,15 +626,15 @@ class TestCascadeResetFdsRag(unittest.TestCase):
class TestPathNormalization(unittest.TestCase):
def test_empty_path_normalises_to_root(self):
- self.assertEqual(_inheritFlags._normalisePath(""), "/")
- self.assertEqual(_inheritFlags._normalisePath(None), "/")
+ self.assertEqual(_inheritFlags.normalisePath(""), "/")
+ self.assertEqual(_inheritFlags.normalisePath(None), "/")
def test_trailing_slash_stripped(self):
- self.assertEqual(_inheritFlags._normalisePath("/foo/"), "/foo")
- self.assertEqual(_inheritFlags._normalisePath("/"), "/")
+ self.assertEqual(_inheritFlags.normalisePath("/foo/"), "/foo")
+ self.assertEqual(_inheritFlags.normalisePath("/"), "/")
def test_leading_slash_added(self):
- self.assertEqual(_inheritFlags._normalisePath("foo/bar"), "/foo/bar")
+ self.assertEqual(_inheritFlags.normalisePath("foo/bar"), "/foo/bar")
# ===========================================================================
diff --git a/tests/unit/services/test_queryValidator.py b/tests/unit/services/test_queryValidator.py
index ed3235f0..0fb0b4a4 100644
--- a/tests/unit/services/test_queryValidator.py
+++ b/tests/unit/services/test_queryValidator.py
@@ -15,7 +15,7 @@ from __future__ import annotations
import pytest
-from modules.shared import fkRegistry
+from modules.dbHelpers import fkRegistry
from modules.serviceCenter.services.serviceAgent.datamodelOntology import (
Constraint,
ConstraintRule,
diff --git a/tests/unit/services/test_ragLimits.py b/tests/unit/services/test_ragLimits.py
index bb336ed3..1ab5c403 100644
--- a/tests/unit/services/test_ragLimits.py
+++ b/tests/unit/services/test_ragLimits.py
@@ -11,7 +11,7 @@ from __future__ import annotations
import unittest
-from modules.serviceCenter.services.serviceKnowledge import _ragLimits
+from modules.serviceCenter.services.serviceKnowledge import ragLimits as _ragLimits
class TestGetDefaults(unittest.TestCase):
diff --git a/tests/unit/services/test_trusteeOntology.py b/tests/unit/services/test_trusteeOntology.py
index c9945ea4..89d714c6 100644
--- a/tests/unit/services/test_trusteeOntology.py
+++ b/tests/unit/services/test_trusteeOntology.py
@@ -35,7 +35,7 @@ from modules.serviceCenter.services.serviceAgent.ontologyToPromptCompiler import
compileOntologyToPrompt,
)
from modules.serviceCenter.services.serviceAgent.queryValidator import QueryValidator
-from modules.shared import fkRegistry
+from modules.dbHelpers import fkRegistry
@pytest.fixture(scope="module", autouse=True)
diff --git a/tests/unit/services/test_udbNodes.py b/tests/unit/services/test_udbNodes.py
index a7454d85..f9fae171 100644
--- a/tests/unit/services/test_udbNodes.py
+++ b/tests/unit/services/test_udbNodes.py
@@ -24,7 +24,7 @@ from modules.serviceCenter.services.serviceKnowledge.udbNodes import (
FdsWorkspaceNode,
FdsTableNode,
FdsFieldNode,
- _isFeatureAdmin,
+ isFeatureAdmin,
)
@@ -422,27 +422,27 @@ class TestIsFeatureAdmin(unittest.TestCase):
def test_no_access_returns_false(self):
rootIf = MagicMock()
rootIf.getFeatureAccess.return_value = None
- self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
+ self.assertFalse(isFeatureAdmin(rootIf, "user-1", "fi1"))
def test_no_roles_returns_false(self):
rootIf = MagicMock()
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
rootIf.getRoleIdsForFeatureAccess.return_value = []
- self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
+ self.assertFalse(isFeatureAdmin(rootIf, "user-1", "fi1"))
def test_non_admin_role_returns_false(self):
rootIf = MagicMock()
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "trustee-user"}
- self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
+ self.assertFalse(isFeatureAdmin(rootIf, "user-1", "fi1"))
def test_admin_role_returns_true(self):
rootIf = MagicMock()
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "workspace-admin"}
- self.assertTrue(_isFeatureAdmin(rootIf, "user-1", "fi1"))
+ self.assertTrue(isFeatureAdmin(rootIf, "user-1", "fi1"))
if __name__ == "__main__":
diff --git a/tests/unit/workflow/test_trusteeQueryData.py b/tests/unit/workflow/test_trusteeQueryData.py
index b0bbae3b..93e0f4c5 100644
--- a/tests/unit/workflow/test_trusteeQueryData.py
+++ b/tests/unit/workflow/test_trusteeQueryData.py
@@ -4,7 +4,7 @@
import pytest
-from modules.workflows.methods.methodTrustee.actions.queryData import (
+from modules.features.trustee.workflows.methodTrustee.actions.queryData import (
_accountMatcher,
_normalizeText,
_parseFilterJson,
From 2b208ee504e786f177716550c625ea3044db4218 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 7 Jun 2026 08:39:19 +0200
Subject: [PATCH 04/16] fix(test): update methodTrustee path in signature
validator parametrize
Co-authored-by: Cursor
---
.../interfaceFeatureGraphicalEditor.py | 58 +++++++++----------
.../test_action_signature_validator.py | 2 +-
2 files changed, 29 insertions(+), 31 deletions(-)
diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py
index aacc9e45..092389c6 100644
--- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py
+++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py
@@ -46,8 +46,6 @@ from modules.datamodels.datamodelWorkflowAutomation import (
AutoRun,
AutoStepLog,
AutoTask,
- Automation2Workflow,
- Automation2WorkflowRun,
)
from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph
from modules.connectors.connectorDbPostgre import DatabaseConnector
@@ -96,11 +94,11 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
dbPort=dbPort,
userId=None,
)
- if not connector._ensureTableExists(Automation2Workflow):
- logger.warning("GraphicalEditor schedule: table Automation2Workflow does not exist yet")
+ if not connector._ensureTableExists(AutoWorkflow):
+ logger.warning("GraphicalEditor schedule: table AutoWorkflow does not exist yet")
return []
records = connector.getRecordset(
- Automation2Workflow,
+ AutoWorkflow,
recordFilter=None,
)
raw_count = len(records) if records else 0
@@ -191,7 +189,7 @@ class GraphicalEditorObjects:
def getWorkflows(self, active: Optional[bool] = None) -> List[Dict[str, Any]]:
"""Get all workflows for this mandate (cross-instance)."""
- if not self.db._ensureTableExists(Automation2Workflow):
+ if not self.db._ensureTableExists(AutoWorkflow):
return []
rf: Dict[str, Any] = {
"mandateId": self.mandateId,
@@ -199,7 +197,7 @@ class GraphicalEditorObjects:
if active is not None:
rf["active"] = active
records = self.db.getRecordset(
- Automation2Workflow,
+ AutoWorkflow,
recordFilter=rf,
)
rows = [dict(r) for r in records] if records else []
@@ -209,10 +207,10 @@ class GraphicalEditorObjects:
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]:
"""Get a single workflow by ID (mandate-scoped, cross-instance)."""
- if not self.db._ensureTableExists(Automation2Workflow):
+ if not self.db._ensureTableExists(AutoWorkflow):
return None
records = self.db.getRecordset(
- Automation2Workflow,
+ AutoWorkflow,
recordFilter={
"id": workflowId,
"mandateId": self.mandateId,
@@ -235,7 +233,7 @@ class GraphicalEditorObjects:
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"))
- created = self.db.recordCreate(Automation2Workflow, data)
+ created = self.db.recordCreate(AutoWorkflow, data)
out = dict(created)
out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations"))
try:
@@ -258,7 +256,7 @@ class GraphicalEditorObjects:
g = {}
inv = data["invocations"] if "invocations" in data else existing.get("invocations")
data["invocations"] = invocations_synced_with_graph(g, inv)
- updated = self.db.recordModify(Automation2Workflow, workflowId, data)
+ updated = self.db.recordModify(AutoWorkflow, workflowId, data)
out = dict(updated)
out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations"))
try:
@@ -273,7 +271,7 @@ class GraphicalEditorObjects:
existing = self.getWorkflow(workflowId)
if not existing:
return False
- self.db.recordDelete(Automation2Workflow, workflowId)
+ self.db.recordDelete(AutoWorkflow, workflowId)
try:
from modules.shared.callbackRegistry import callbackRegistry
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
@@ -305,15 +303,15 @@ class GraphicalEditorObjects:
"mandateId": ctx.get("mandateId") or self.mandateId,
"ownerId": ctx.get("userId") or (self.currentUser.id if self.currentUser else None),
}
- created = self.db.recordCreate(Automation2WorkflowRun, data)
+ created = self.db.recordCreate(AutoRun, data)
return dict(created)
def getRun(self, runId: str) -> Optional[Dict[str, Any]]:
"""Get a run by ID."""
- if not self.db._ensureTableExists(Automation2WorkflowRun):
+ if not self.db._ensureTableExists(AutoRun):
return None
records = self.db.getRecordset(
- Automation2WorkflowRun,
+ AutoRun,
recordFilter={"id": runId},
)
if not records:
@@ -345,29 +343,29 @@ class GraphicalEditorObjects:
updates["context"] = context
if not updates:
return run
- updated = self.db.recordModify(Automation2WorkflowRun, runId, updates)
+ updated = self.db.recordModify(AutoRun, runId, updates)
return dict(updated)
def getRunsByWorkflow(self, workflowId: str) -> List[Dict[str, Any]]:
"""Get all runs for a workflow."""
- if not self.db._ensureTableExists(Automation2WorkflowRun):
+ if not self.db._ensureTableExists(AutoRun):
return []
records = self.db.getRecordset(
- Automation2WorkflowRun,
+ AutoRun,
recordFilter={"workflowId": workflowId},
)
return [dict(r) for r in records] if records else []
def getRecentCompletedRuns(self, limit: int = 20) -> List[Dict[str, Any]]:
"""Get recent runs (all statuses) for workflows in this instance."""
- if not self.db._ensureTableExists(Automation2WorkflowRun):
+ if not self.db._ensureTableExists(AutoRun):
return []
workflows = self.getWorkflows()
wf_ids = [w["id"] for w in workflows if w.get("id")]
if not wf_ids:
return []
records = self.db.getRecordset(
- Automation2WorkflowRun,
+ AutoRun,
recordFilter={},
)
if not records:
@@ -385,10 +383,10 @@ class GraphicalEditorObjects:
def getRunsWaitingForEmail(self) -> List[Dict[str, Any]]:
"""Get all paused runs waiting for a new email (for background poller)."""
- if not self.db._ensureTableExists(Automation2WorkflowRun):
+ if not self.db._ensureTableExists(AutoRun):
return []
records = self.db.getRecordset(
- Automation2WorkflowRun,
+ AutoRun,
recordFilter={"status": "paused"},
)
if not records:
@@ -426,15 +424,15 @@ class GraphicalEditorObjects:
"status": "pending",
"result": None,
}
- created = self.db.recordCreate(Automation2HumanTask, data)
+ created = self.db.recordCreate(AutoTask, data)
return dict(created)
def getTask(self, taskId: str) -> Optional[Dict[str, Any]]:
"""Get a task by ID."""
- if not self.db._ensureTableExists(Automation2HumanTask):
+ if not self.db._ensureTableExists(AutoTask):
return None
records = self.db.getRecordset(
- Automation2HumanTask,
+ AutoTask,
recordFilter={"id": taskId},
)
if not records:
@@ -453,7 +451,7 @@ class GraphicalEditorObjects:
updates["result"] = result
if not updates:
return task
- updated = self.db.recordModify(Automation2HumanTask, taskId, updates)
+ updated = self.db.recordModify(AutoTask, taskId, updates)
return dict(updated)
def getTasks(
@@ -464,7 +462,7 @@ class GraphicalEditorObjects:
assigneeId: str = None,
) -> List[Dict[str, Any]]:
"""Get tasks with optional filters."""
- if not self.db._ensureTableExists(Automation2HumanTask):
+ if not self.db._ensureTableExists(AutoTask):
return []
base_rf: Dict[str, Any] = {}
if workflowId:
@@ -476,8 +474,8 @@ class GraphicalEditorObjects:
if assigneeId:
rf_assigned = {**base_rf, "assigneeId": assigneeId}
rf_unassigned = {**base_rf, "assigneeId": None}
- records1 = self.db.getRecordset(Automation2HumanTask, recordFilter=rf_assigned)
- records2 = self.db.getRecordset(Automation2HumanTask, recordFilter=rf_unassigned)
+ records1 = self.db.getRecordset(AutoTask, recordFilter=rf_assigned)
+ records2 = self.db.getRecordset(AutoTask, recordFilter=rf_unassigned)
seen = set()
items = []
for r in (records1 or []) + (records2 or []):
@@ -488,7 +486,7 @@ class GraphicalEditorObjects:
items.append(rec)
else:
records = self.db.getRecordset(
- Automation2HumanTask,
+ AutoTask,
recordFilter=base_rf if base_rf else None,
)
items = [dict(r) for r in records] if records else []
diff --git a/tests/unit/methods/test_action_signature_validator.py b/tests/unit/methods/test_action_signature_validator.py
index 7afd4597..fa4aa71f 100644
--- a/tests/unit/methods/test_action_signature_validator.py
+++ b/tests/unit/methods/test_action_signature_validator.py
@@ -255,7 +255,7 @@ def _instantiateMethod(methodCls):
@pytest.mark.parametrize("modulePath,className", [
- ("modules.workflows.methods.methodTrustee.methodTrustee", "MethodTrustee"),
+ ("modules.features.trustee.workflows.methodTrustee.methodTrustee", "MethodTrustee"),
("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine"),
("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint"),
("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook"),
From 39aba4cca814520a1aaa554e52cd4a5463c3feea Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 7 Jun 2026 22:26:18 +0200
Subject: [PATCH 05/16] before refactory workflowAutomation
---
app.py | 25 +-
modules/datamodels/datamodelNavigation.py | 48 ++
.../datamodels/datamodelWorkflowAutomation.py | 18 +-
modules/datamodels/serviceExceptions.py | 25 +
modules/demoConfigs/investorDemo2026.py | 4 +-
modules/demoConfigs/pwgDemo2026.py | 2 +-
.../graphicalEditor/mainGraphicalEditor.py | 195 --------
.../routeFeatureGraphicalEditor.py | 8 +-
modules/interfaces/interfaceBootstrap.py | 2 +-
modules/routes/routeAutomationWorkspace.py | 6 +-
modules/routes/routeSystem.py | 5 +-
modules/routes/routeWorkflowAutomation.py | 453 ++++++++++++++++++
modules/routes/routeWorkflowDashboard.py | 29 +-
.../services/serviceAgent/workflowTools.py | 5 +
modules/shared/documentUtils.py | 49 ++
modules/system/i18nBootSync.py | 2 +
.../workflows/automation2/executionEngine.py | 50 ++
.../executors/actionNodeExecutor.py | 47 +-
.../automation2/executors/inputExecutor.py | 22 +-
.../methodContext/actions/setContext.py | 2 +-
.../methods/methodFile/actions/create.py | 2 +-
modules/workflows/scheduler/mainScheduler.py | 4 +-
22 files changed, 713 insertions(+), 290 deletions(-)
create mode 100644 modules/routes/routeWorkflowAutomation.py
diff --git a/app.py b/app.py
index c91212e3..d8104fad 100644
--- a/app.py
+++ b/app.py
@@ -481,7 +481,15 @@ async def lifespan(app: FastAPI):
except RuntimeError:
pass
eventManager.start()
-
+
+ # --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) ---
+ try:
+ from modules.workflows.scheduler.mainScheduler import start as _startWorkflowScheduler
+ _startWorkflowScheduler(eventUser)
+ logger.info("WorkflowAutomation scheduler started (system lifespan)")
+ except Exception as e:
+ logger.error(f"WorkflowAutomation scheduler failed to start: {e}")
+
# Register audit log cleanup scheduler
from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler()
@@ -562,6 +570,18 @@ async def lifespan(app: FastAPI):
# 3. Stop scheduler (removes all pending cron/interval jobs)
eventManager.stop()
+ # 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan)
+ try:
+ from modules.workflows.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
+ _stopEmailPoller(eventUser)
+ except Exception as e:
+ logger.warning(f"Email poller stop failed: {e}")
+
# 4. Stop Feature Containers (Plug&Play)
try:
mainModules = loadFeatureMainModules()
@@ -849,6 +869,9 @@ 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)
+
# ============================================================================
# PLUG&PLAY FEATURE ROUTERS
# Dynamically load routers from feature containers in modules/features/
diff --git a/modules/datamodels/datamodelNavigation.py b/modules/datamodels/datamodelNavigation.py
index 2fc278ef..eb9d3b69 100644
--- a/modules/datamodels/datamodelNavigation.py
+++ b/modules/datamodels/datamodelNavigation.py
@@ -158,6 +158,54 @@ NAVIGATION_SECTIONS = [
},
],
},
+ # --- Workflow-Automation (System-Komponente, cross-mandate) ---
+ {
+ "id": "workflowAutomation",
+ "title": t("Workflow-Automation"),
+ "order": 25,
+ "items": [
+ {
+ "id": "wa-workflows",
+ "objectKey": "ui.system.workflowAutomation.workflows",
+ "label": t("Workflows"),
+ "icon": "FaSitemap",
+ "path": "/workflow-automation?tab=workflows",
+ "order": 10,
+ },
+ {
+ "id": "wa-editor",
+ "objectKey": "ui.system.workflowAutomation.editor",
+ "label": t("Editor"),
+ "icon": "FaProjectDiagram",
+ "path": "/workflow-automation?tab=editor",
+ "order": 20,
+ },
+ {
+ "id": "wa-templates",
+ "objectKey": "ui.system.workflowAutomation.templates",
+ "label": t("Vorlagen"),
+ "icon": "FaCopy",
+ "path": "/workflow-automation?tab=templates",
+ "order": 30,
+ },
+ {
+ "id": "wa-runs",
+ "objectKey": "ui.system.workflowAutomation.runs",
+ "label": t("Läufe"),
+ "icon": "FaPlay",
+ "path": "/workflow-automation?tab=runs",
+ "order": 40,
+ },
+ {
+ "id": "wa-tasks",
+ "objectKey": "ui.system.workflowAutomation.tasks",
+ "label": t("Tasks"),
+ "icon": "FaTasks",
+ "path": "/workflow-automation?tab=tasks",
+ "order": 50,
+ },
+ ],
+ },
# --- Administration (with subgroups) ---
{
"id": "admin",
diff --git a/modules/datamodels/datamodelWorkflowAutomation.py b/modules/datamodels/datamodelWorkflowAutomation.py
index 5f9cb7b2..c9957c25 100644
--- a/modules/datamodels/datamodelWorkflowAutomation.py
+++ b/modules/datamodels/datamodelWorkflowAutomation.py
@@ -77,14 +77,26 @@ class AutoWorkflow(PowerOnModel):
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
},
)
- featureInstanceId: str = Field(
- description="Feature instance ID (GE owner instance / RBAC scope)",
+ featureInstanceId: Optional[str] = Field(
+ default=None,
+ description="Feature instance ID (legacy GE owner — being phased out; NULL for mandate-level workflows)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Feature-Instanz-ID",
- "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
+ "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True},
+ },
+ )
+ runAsPrincipal: Optional[str] = Field(
+ default=None,
+ description="Identity (userId or service-account) under which this workflow executes. Governs RBAC for data access at runtime.",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": False,
+ "label": "Ausführungsidentität",
+ "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True},
},
)
targetFeatureInstanceId: Optional[str] = Field(
diff --git a/modules/datamodels/serviceExceptions.py b/modules/datamodels/serviceExceptions.py
index 2aa94d95..7585c6a9 100644
--- a/modules/datamodels/serviceExceptions.py
+++ b/modules/datamodels/serviceExceptions.py
@@ -144,3 +144,28 @@ class BillingContextError(Exception):
def __init__(self, message: str = None):
self.message = message or "Billing context incomplete - AI call blocked"
super().__init__(self.message)
+
+
+# ============================================================================
+# Workflow execution pause exceptions
+# (Canonical location — formerly in automation2/executors/inputExecutor.py)
+# ============================================================================
+
+class PauseForHumanTaskError(Exception):
+ """Raised when execution must pause for a human task. Contains runId, taskId."""
+
+ def __init__(self, runId: str, taskId: str, nodeId: str):
+ self.runId = runId
+ self.taskId = taskId
+ self.nodeId = nodeId
+ super().__init__(f"Pause for human task {taskId} (run {runId}, node {nodeId})")
+
+
+class PauseForEmailWaitError(Exception):
+ """Raised when execution must pause waiting for a new email. Background poller will resume."""
+
+ def __init__(self, runId: str, nodeId: str, waitConfig: Dict[str, Any]):
+ self.runId = runId
+ self.nodeId = nodeId
+ self.waitConfig = waitConfig
+ super().__init__(f"Pause for email wait (run {runId}, node {nodeId})")
diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py
index 6855a63c..0f7b0863 100644
--- a/modules/demoConfigs/investorDemo2026.py
+++ b/modules/demoConfigs/investorDemo2026.py
@@ -44,7 +44,7 @@ _USER = {
_FEATURES_HAPPYLIFE = [
{"code": "workspace", "label": "Dokumentenablage"},
{"code": "trustee", "label": "Buchhaltung"},
- {"code": "graphicalEditor", "label": "Automationen"},
+ {"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component
{"code": "neutralization", "label": "Datenschutz"},
]
_FEATURES_ALPINA = [
@@ -52,7 +52,7 @@ _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"},
+ {"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component
{"code": "neutralization", "label": "Datenschutz"},
]
diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py
index 968aabf8..6e21e45a 100644
--- a/modules/demoConfigs/pwgDemo2026.py
+++ b/modules/demoConfigs/pwgDemo2026.py
@@ -49,7 +49,7 @@ _USER = {
_FEATURES_PWG = [
{"code": "workspace", "label": "Dokumentenablage PWG"},
{"code": "trustee", "label": "Buchhaltung PWG"},
- {"code": "graphicalEditor", "label": "PWG Automationen"},
+ {"code": "graphicalEditor", "label": "PWG Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component
{"code": "neutralization", "label": "Datenschutz"},
]
diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/features/graphicalEditor/mainGraphicalEditor.py
index bf50abb2..f88ccfdc 100644
--- a/modules/features/graphicalEditor/mainGraphicalEditor.py
+++ b/modules/features/graphicalEditor/mainGraphicalEditor.py
@@ -26,25 +26,6 @@ REQUIRED_SERVICES = [
{"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}},
]
FEATURE_LABEL = t("Grafischer Editor", context="UI")
-FEATURE_ICON = "mdi-sitemap"
-
-UI_OBJECTS = [
- {
- "objectKey": "ui.feature.graphicalEditor.editor",
- "label": t("Editor", context="UI"),
- "meta": {"area": "editor"}
- },
- {
- "objectKey": "ui.feature.graphicalEditor.templates",
- "label": t("Vorlagen", context="UI"),
- "meta": {"area": "templates"}
- },
- {
- "objectKey": "ui.feature.graphicalEditor.workflows-tasks",
- "label": t("Tasks", context="UI"),
- "meta": {"area": "tasks"}
- },
-]
RESOURCE_OBJECTS = [
{
@@ -64,41 +45,6 @@ RESOURCE_OBJECTS = [
},
]
-TEMPLATE_ROLES = [
- {
- "roleLabel": "graphicalEditor-viewer",
- "description": "Grafischer Editor Betrachter - Workflows ansehen (nur lesen)",
- "accessRules": [
- {"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True},
- {"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True},
- {"context": "UI", "item": "ui.feature.graphicalEditor.templates", "view": True},
- {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
- ],
- },
- {
- "roleLabel": "graphicalEditor-user",
- "description": "Grafischer Editor Benutzer - Flow-Builder nutzen",
- "accessRules": [
- {"context": "UI", "item": "ui.feature.graphicalEditor.editor", "view": True},
- {"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True},
- {"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True},
- {"context": "UI", "item": "ui.feature.graphicalEditor.templates", "view": True},
- {"context": "RESOURCE", "item": "resource.feature.graphicalEditor.dashboard", "view": True},
- {"context": "RESOURCE", "item": "resource.feature.graphicalEditor.node-types", "view": True},
- {"context": "RESOURCE", "item": "resource.feature.graphicalEditor.execute", "view": True},
- {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
- ],
- },
- {
- "roleLabel": "graphicalEditor-admin",
- "description": "Grafischer Editor Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)",
- "accessRules": [
- {"context": "UI", "item": None, "view": True},
- {"context": "RESOURCE", "item": None, "view": True},
- {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
- ],
- },
-]
def getRequiredServiceKeys() -> List[str]:
@@ -186,28 +132,6 @@ class _GraphicalEditorServiceHub:
generation = None
-async def onStart(eventUser) -> None:
- """Feature startup: start consolidated scheduler."""
- from modules.workflows.scheduler.mainScheduler import start as startScheduler
- startScheduler(eventUser)
-
-
-async def onStop(eventUser) -> None:
- """Feature shutdown - stop scheduler and email poller."""
- from modules.workflows.scheduler.mainScheduler import stop as stopScheduler
- stopScheduler()
- from modules.features.graphicalEditor.emailPoller import stop as stopEmailPoller
- stopEmailPoller(eventUser)
-
-
-def getFeatureDefinition() -> Dict[str, Any]:
- """Return the feature definition for registration."""
- return {
- "code": FEATURE_CODE,
- "label": FEATURE_LABEL,
- "icon": FEATURE_ICON,
- "autoCreateInstance": False,
- }
# ---------------------------------------------------------------------------
@@ -473,125 +397,6 @@ def _buildSystemTemplates():
]
-def getUiObjects() -> List[Dict[str, Any]]:
- """Return UI objects for RBAC catalog registration."""
- return UI_OBJECTS
-
-
def getResourceObjects() -> List[Dict[str, Any]]:
"""Return resource objects for RBAC catalog registration."""
return RESOURCE_OBJECTS
-
-
-def getTemplateRoles() -> List[Dict[str, Any]]:
- """Return template roles for this feature."""
- return TEMPLATE_ROLES
-
-
-def registerFeature(catalogService) -> bool:
- """Register this feature's RBAC objects in the catalog."""
- try:
- for uiObj in UI_OBJECTS:
- catalogService.registerUiObject(
- featureCode=FEATURE_CODE,
- objectKey=uiObj["objectKey"],
- label=uiObj["label"],
- meta=uiObj.get("meta")
- )
- for resObj in RESOURCE_OBJECTS:
- catalogService.registerResourceObject(
- featureCode=FEATURE_CODE,
- objectKey=resObj["objectKey"],
- label=resObj["label"],
- meta=resObj.get("meta")
- )
- _syncTemplateRolesToDb()
- logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
- return True
- except Exception as e:
- logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
- return False
-
-
-def _syncTemplateRolesToDb() -> int:
- """Sync template roles and their AccessRules to database.
- Also syncs rules to mandate-specific roles (same roleLabel) so new UI objects
- become visible after gateway restart without manual role update.
- """
- try:
- from modules.interfaces.interfaceDbApp import getRootInterface
- from modules.datamodels.datamodelRbac import Role
- from modules.datamodels.datamodelUtils import coerce_text_multilingual
-
- rootInterface = getRootInterface()
- existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
- existingLabels = {r.roleLabel: str(r.id) for r in existingRoles if r.mandateId is None}
- created = 0
-
- for template in TEMPLATE_ROLES:
- roleLabel = template["roleLabel"]
- if roleLabel in existingLabels:
- roleId = existingLabels[roleLabel]
- else:
- newRole = Role(
- roleLabel=roleLabel,
- description=coerce_text_multilingual(template.get("description", {})),
- featureCode=FEATURE_CODE,
- mandateId=None,
- featureInstanceId=None,
- isSystemRole=False
- )
- rec = rootInterface.db.recordCreate(Role, newRole.model_dump())
- roleId = rec.get("id")
- created += 1
- logger.info(f"Created template role '{roleLabel}' for {FEATURE_CODE}")
-
- _ensureAccessRulesForRole(rootInterface, roleId, template.get("accessRules", []))
-
- for r in existingRoles:
- if r.mandateId and r.roleLabel == roleLabel:
- added = _ensureAccessRulesForRole(
- rootInterface, str(r.id), template.get("accessRules", [])
- )
- if added:
- logger.debug(f"Added {added} access rules to mandate role {r.id}")
- return created
- except Exception as e:
- logger.warning(f"Template role sync for {FEATURE_CODE}: {e}")
- return 0
-
-
-def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int:
- """Ensure AccessRules exist for a role based on templates."""
- from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
-
- existingRules = rootInterface.getAccessRulesByRole(roleId)
- existingSignatures = {
- (r.context.value if r.context else None, r.item)
- for r in existingRules
- }
- created = 0
- for t in ruleTemplates:
- context = t.get("context", "UI")
- item = t.get("item")
- sig = (context, item)
- if sig in existingSignatures:
- continue
- ctx_enum = (
- AccessRuleContext.UI if context == "UI" else
- AccessRuleContext.DATA if context == "DATA" else
- AccessRuleContext.RESOURCE if context == "RESOURCE" else context
- )
- newRule = AccessRule(
- roleId=roleId,
- context=ctx_enum,
- item=item,
- view=t.get("view", False),
- read=t.get("read"),
- create=t.get("create"),
- update=t.get("update"),
- delete=t.get("delete"),
- )
- rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
- created += 1
- return created
diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
index 20a2708b..38d9d769 100644
--- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
+++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
@@ -1,7 +1,10 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
-GraphicalEditor routes - node-types, execute, workflows, runs, tasks, connections, browse.
+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
@@ -644,7 +647,8 @@ def get_templates(
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
- enrichRowsWithFkLabels(templates, AutoWorkflow, db=iface.db)
+ from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
+ enrichRowsWithFkLabels(templates, AutoWorkflow, db=_getRootIface().db)
if mode == "filterValues":
if not column:
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 5ac5d089..9a6e2e26 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -1610,7 +1610,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
"resource.store.workspace",
"resource.store.commcoach",
"resource.store.trustee",
- "resource.store.graphicalEditor",
+ "resource.store.graphicalEditor", # DEPRECATED: will move with WorkflowAutomation code restructuring
]
storeRules = []
diff --git a/modules/routes/routeAutomationWorkspace.py b/modules/routes/routeAutomationWorkspace.py
index 09c5238c..a93fff70 100644
--- a/modules/routes/routeAutomationWorkspace.py
+++ b/modules/routes/routeAutomationWorkspace.py
@@ -21,12 +21,12 @@ 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.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
+from modules.datamodels.datamodelWorkflowAutomation import (
AutoRun,
AutoStepLog,
AutoWorkflow,
+ GRAPHICAL_EDITOR_DATABASE,
)
-from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui
from modules.shared.i18nRegistry import apiRouteContext
@@ -40,7 +40,7 @@ router = APIRouter(prefix="/api/automations/runs", tags=["AutomationWorkspace"])
def _getDb() -> DatabaseConnector:
return DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
- dbDatabase=graphicalEditorDatabase,
+ 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)),
diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py
index 56568cd9..217dfa14 100644
--- a/modules/routes/routeSystem.py
+++ b/modules/routes/routeSystem.py
@@ -105,9 +105,6 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
elif featureCode == "realestate":
from modules.features.realEstate.mainRealEstate import UI_OBJECTS
return UI_OBJECTS
- elif featureCode == "graphicalEditor":
- from modules.features.graphicalEditor.mainGraphicalEditor import UI_OBJECTS
- return UI_OBJECTS
elif featureCode == "teamsbot":
from modules.features.teamsbot.mainTeamsbot import UI_OBJECTS
return UI_OBJECTS
@@ -841,7 +838,7 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]:
from modules.shared.configuration import APP_CONFIG
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelPagination import PaginationParams
- from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
+ from modules.datamodels.datamodelWorkflowAutomation import (
AutoWorkflow, AutoRun,
)
diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py
new file mode 100644
index 00000000..6ce6fb21
--- /dev/null
+++ b/modules/routes/routeWorkflowAutomation.py
@@ -0,0 +1,453 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""
+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.
+
+RBAC model:
+ - Read: mandate membership (user sees workflows in own mandates)
+ - Write/Execute: mandate admin or isPlatformAdmin
+ - isPlatformAdmin bypasses all checks
+"""
+
+import json
+import logging
+import time
+from typing import Optional, List, Dict, Any
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+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.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
+
+routeApiMsg = apiRouteContext("routeWorkflowAutomation")
+
+logger = logging.getLogger(__name__)
+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
+# ---------------------------------------------------------------------------
+
+@router.get("/workflows")
+async def _listWorkflows(
+ request: RequestContext = Depends(getRequestContext),
+ pagination: Optional[str] = Query(default=None),
+ mandateId: Optional[str] = Query(default=None),
+):
+ db = _getDb()
+ try:
+ db._ensureTableExists(AutoWorkflow)
+ scopeFilter = _scopedWorkflowFilter(request)
+ if mandateId and scopeFilter is not None:
+ if mandateId not in (scopeFilter.get("mandateId") or []):
+ return {"items": [], "total": 0}
+ scopeFilter = {"mandateId": mandateId}
+ elif mandateId and scopeFilter is None:
+ scopeFilter = {"mandateId": mandateId}
+
+ params = _parsePagination(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}
+ finally:
+ db.close()
+
+
+@router.get("/workflows/{workflowId}")
+async def _getWorkflow(
+ workflowId: str,
+ request: RequestContext = Depends(getRequestContext),
+):
+ db = _getDb()
+ try:
+ db._ensureTableExists(AutoWorkflow)
+ wf = db.getRecord(AutoWorkflow, workflowId)
+ if not wf:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+ _validateWorkflowAccess(request, wf, "read")
+ return wf
+ finally:
+ db.close()
+
+
+@router.post("/workflows")
+async def _createWorkflow(
+ request: RequestContext = Depends(getRequestContext),
+ body: Dict[str, Any] = {},
+):
+ mandateId = body.get("mandateId")
+ if not mandateId:
+ raise HTTPException(status_code=400, detail="mandateId required")
+
+ _validateWorkflowAccess(request, {"mandateId": mandateId}, "write")
+
+ db = _getDb()
+ try:
+ db._ensureTableExists(AutoWorkflow)
+ import uuid
+ data = {**body, "id": str(uuid.uuid4())}
+ if request.user:
+ data.setdefault("runAsPrincipal", str(request.user.id))
+ rec = db.recordCreate(AutoWorkflow, data)
+ return rec
+ finally:
+ db.close()
+
+
+@router.put("/workflows/{workflowId}")
+async def _updateWorkflow(
+ workflowId: str,
+ request: RequestContext = Depends(getRequestContext),
+ body: Dict[str, Any] = {},
+):
+ db = _getDb()
+ try:
+ db._ensureTableExists(AutoWorkflow)
+ wf = db.getRecord(AutoWorkflow, workflowId)
+ _validateWorkflowAccess(request, wf, "write")
+ updated = db.recordModify(AutoWorkflow, workflowId, body)
+ return updated
+ finally:
+ db.close()
+
+
+@router.delete("/workflows/{workflowId}")
+async def _deleteWorkflow(
+ workflowId: str,
+ request: RequestContext = Depends(getRequestContext),
+):
+ db = _getDb()
+ 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)
+ return {"deleted": True, "workflowId": workflowId}
+ finally:
+ db.close()
+
+
+# ---------------------------------------------------------------------------
+# Runs
+# ---------------------------------------------------------------------------
+
+@router.get("/runs")
+async def _listRuns(
+ request: RequestContext = Depends(getRequestContext),
+ pagination: Optional[str] = Query(default=None),
+ mandateId: Optional[str] = Query(default=None),
+ workflowId: Optional[str] = Query(default=None),
+):
+ db = _getDb()
+ try:
+ db._ensureTableExists(AutoRun)
+ scopeFilter = _scopedRunFilter(request)
+ if mandateId:
+ if scopeFilter is None:
+ scopeFilter = {"mandateId": mandateId}
+ elif "mandateId" in scopeFilter:
+ if mandateId not in scopeFilter["mandateId"]:
+ return {"items": [], "total": 0}
+ scopeFilter = {"mandateId": mandateId}
+ if workflowId:
+ scopeFilter = {**(scopeFilter or {}), "workflowId": workflowId}
+
+ params = _parsePagination(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}
+ finally:
+ db.close()
+
+
+@router.get("/runs/{runId}")
+async def _getRun(
+ runId: str,
+ request: RequestContext = Depends(getRequestContext),
+):
+ db = _getDb()
+ try:
+ db._ensureTableExists(AutoRun)
+ run = db.getRecord(AutoRun, runId)
+ if not run:
+ raise HTTPException(status_code=404, detail="Run not found")
+
+ wfId = run.get("workflowId")
+ if wfId:
+ wf = db.getRecord(AutoWorkflow, wfId)
+ _validateWorkflowAccess(request, wf, "read")
+ return run
+ finally:
+ db.close()
+
+
+# ---------------------------------------------------------------------------
+# Tasks
+# ---------------------------------------------------------------------------
+
+@router.get("/tasks")
+async def _listTasks(
+ request: RequestContext = Depends(getRequestContext),
+ pagination: Optional[str] = Query(default=None),
+ status: Optional[str] = Query(default=None),
+):
+ db = _getDb()
+ try:
+ db._ensureTableExists(AutoTask)
+ scopeFilter: Optional[Dict[str, Any]] = None
+
+ if not request.isPlatformAdmin:
+ userId = str(request.user.id) if request.user else None
+ if not userId:
+ return {"items": [], "total": 0}
+ scopeFilter = {"assigneeId": userId}
+
+ if status:
+ scopeFilter = {**(scopeFilter or {}), "status": status}
+
+ params = _parsePagination(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}
+ finally:
+ db.close()
+
+
+# ---------------------------------------------------------------------------
+# Versions
+# ---------------------------------------------------------------------------
+
+@router.get("/workflows/{workflowId}/versions")
+async def _listVersions(
+ workflowId: str,
+ request: RequestContext = Depends(getRequestContext),
+):
+ db = _getDb()
+ try:
+ db._ensureTableExists(AutoWorkflow)
+ wf = db.getRecord(AutoWorkflow, workflowId)
+ _validateWorkflowAccess(request, wf, "read")
+
+ db._ensureTableExists(AutoVersion)
+ versions = db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId})
+ return {"items": versions or []}
+ finally:
+ db.close()
+
+
+# ---------------------------------------------------------------------------
+# Step logs
+# ---------------------------------------------------------------------------
+
+@router.get("/runs/{runId}/steps")
+async def _listStepLogs(
+ runId: str,
+ request: RequestContext = Depends(getRequestContext),
+):
+ db = _getDb()
+ try:
+ db._ensureTableExists(AutoRun)
+ run = db.getRecord(AutoRun, runId)
+ if not run:
+ raise HTTPException(status_code=404, detail="Run not found")
+
+ wfId = run.get("workflowId")
+ if wfId:
+ wf = db.getRecord(AutoWorkflow, wfId)
+ _validateWorkflowAccess(request, wf, "read")
+
+ db._ensureTableExists(AutoStepLog)
+ steps = db.getRecordset(AutoStepLog, recordFilter={"runId": runId})
+ return {"items": steps or []}
+ finally:
+ db.close()
diff --git a/modules/routes/routeWorkflowDashboard.py b/modules/routes/routeWorkflowDashboard.py
index f29fc557..020e5ec7 100644
--- a/modules/routes/routeWorkflowDashboard.py
+++ b/modules/routes/routeWorkflowDashboard.py
@@ -27,10 +27,10 @@ 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.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
+from modules.datamodels.datamodelWorkflowAutomation import (
AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion,
+ GRAPHICAL_EDITOR_DATABASE,
)
-from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeWorkflowDashboard")
@@ -44,7 +44,7 @@ router = APIRouter(prefix="/api/system/workflow-runs", tags=["WorkflowDashboard"
def _getDb() -> DatabaseConnector:
return DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
- dbDatabase=graphicalEditorDatabase,
+ 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)),
@@ -619,7 +619,8 @@ def get_workflow_runs(
for wf in (wfs or []):
wfMap[wf.get("id")] = wf
- from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels, resolveUserLabels
+ from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
runs = []
for r in pageRuns:
@@ -635,17 +636,20 @@ def get_workflow_runs(
row["featureInstanceId"] = fiid
runs.append(row)
+ appDb = _getRootIface().db
enrichRowsWithFkLabels(
runs,
db=db,
labelResolvers={
- "mandateId": partial(resolveMandateLabels, db),
- "featureInstanceId": partial(resolveInstanceLabels, db),
+ "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}
@@ -808,6 +812,9 @@ def get_system_workflows(
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
@@ -869,17 +876,20 @@ def get_system_workflows(
row["canExecute"] = False
row.pop("graph", None)
items.append(row)
+ _appDb = _getRootIface().db
enrichRowsWithFkLabels(
items,
db=db,
labelResolvers={
- "mandateId": partial(resolveMandateLabels, db),
+ "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 = {
@@ -932,17 +942,20 @@ def get_system_workflows(
row["canExecute"] = False
row.pop("graph", None)
items.append(row)
+ _appDb2 = _getRootIface().db
enrichRowsWithFkLabels(
items,
db=db,
labelResolvers={
- "mandateId": partial(resolveMandateLabels, db),
+ "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 {
diff --git a/modules/serviceCenter/services/serviceAgent/workflowTools.py b/modules/serviceCenter/services/serviceAgent/workflowTools.py
index 82eda22d..32defa2b 100644
--- a/modules/serviceCenter/services/serviceAgent/workflowTools.py
+++ b/modules/serviceCenter/services/serviceAgent/workflowTools.py
@@ -89,6 +89,7 @@ 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)
@@ -306,6 +307,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
paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(node_id))
@@ -436,6 +438,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
nodeTypes = []
for n in STATIC_NODE_TYPES:
@@ -462,6 +465,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
target: Dict[str, Any] = {}
for n in STATIC_NODE_TYPES:
@@ -875,6 +879,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
return _ok(name, {
"fileName": buildFileName(envelope.get("label", "workflow")),
diff --git a/modules/shared/documentUtils.py b/modules/shared/documentUtils.py
index cc08835c..37eec8a5 100644
--- a/modules/shared/documentUtils.py
+++ b/modules/shared/documentUtils.py
@@ -5,7 +5,10 @@ Document utility functions (Layer L0 - shared).
Pure text-processing helpers with zero internal dependencies.
"""
+import base64
+import binascii
import re
+from typing import Any, Optional
def parseInlineRuns(text: str) -> list:
@@ -62,3 +65,49 @@ def parseInlineRuns(text: str) -> list:
runs.append({"type": "text", "value": text[lastEnd:]})
return runs if runs else [{"type": "text", "value": text}]
+
+
+def _looksLikeAsciiBase64Payload(s: str) -> bool:
+ """Heuristic: ActionDocument binary payloads use standard ASCII base64; markdown/text uses other chars."""
+ t = "".join(s.split())
+ if len(t) < 8:
+ return False
+ if not t.isascii():
+ return False
+ return bool(re.fullmatch(r"[A-Za-z0-9+/]+=*", t)) and len(t) % 4 == 0
+
+
+def coerceDocumentDataToBytes(raw: Any) -> Optional[bytes]:
+ """Normalize documentData for DB file persistence.
+
+ ActionDocument conventions (see methodFile.create): binary bodies are carried as ASCII
+ base64 strings; plain markdown/text stays as Unicode. Do not UTF-8-encode a base64
+ literal — that persists the ASCII of the encoding (file looks like base64 gibberish).
+ """
+ if raw is None:
+ return None
+ if isinstance(raw, bytes):
+ return raw if len(raw) > 0 else None
+ if isinstance(raw, bytearray):
+ b = bytes(raw)
+ return b if len(b) > 0 else None
+ if isinstance(raw, memoryview):
+ b = raw.tobytes()
+ return b if len(b) > 0 else None
+ if isinstance(raw, str):
+ stripped = raw.strip()
+ if not stripped:
+ return None
+ if _looksLikeAsciiBase64Payload(stripped):
+ try:
+ decoded = base64.b64decode(stripped, validate=True)
+ except (TypeError, binascii.Error, ValueError):
+ try:
+ decoded = base64.b64decode(stripped)
+ except (binascii.Error, ValueError):
+ decoded = b""
+ if decoded:
+ return decoded
+ b = stripped.encode("utf-8")
+ return b if len(b) > 0 else None
+ return None
diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py
index 96f1b69d..15501a0f 100644
--- a/modules/system/i18nBootSync.py
+++ b/modules/system/i18nBootSync.py
@@ -242,6 +242,7 @@ def _registerNodeLabels():
added += 1
try:
+ # DEPRECATED: will move with WorkflowAutomation code restructuring
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
for nd in STATIC_NODE_TYPES:
_reg(_extractRegistrySourceText(nd.get("label")), "node.label")
@@ -265,6 +266,7 @@ def _registerNodeLabels():
pass
try:
+ # DEPRECATED: will move with WorkflowAutomation code restructuring
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG
for schema in PORT_TYPE_CATALOG.values():
for field in getattr(schema, "fields", []) or []:
diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflows/automation2/executionEngine.py
index d5fdbd0e..b6313342 100644
--- a/modules/workflows/automation2/executionEngine.py
+++ b/modules/workflows/automation2/executionEngine.py
@@ -429,6 +429,52 @@ async def _executeWithRetry(executor, node, context, maxRetries: int = 0, retryD
raise lastError
+def _validateFeatureInstanceMandates(graph: Dict[str, Any], mandateId: str) -> None:
+ """Verify that all FeatureInstanceRef IDs in the graph belong to the workflow's mandate.
+
+ Logs a warning for each mismatch but does NOT abort execution — the node
+ executor will fail on its own with a more specific error if the instance is
+ truly inaccessible. This is a defence-in-depth guard (A0.2).
+ """
+ nodes = graph.get("nodes") if isinstance(graph, dict) else None
+ if not isinstance(nodes, list):
+ return
+ instanceIds: set = set()
+ for node in nodes:
+ if not isinstance(node, dict):
+ continue
+ params = node.get("parameters") or {}
+ ref = params.get("featureInstanceId")
+ if isinstance(ref, dict) and ref.get("$type") == "FeatureInstanceRef":
+ iid = ref.get("id")
+ if iid:
+ instanceIds.add(iid)
+ elif isinstance(ref, str) and ref.strip():
+ instanceIds.add(ref.strip())
+ if not instanceIds:
+ return
+ try:
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ root = getRootInterface()
+ from modules.datamodels.datamodelFeatures import FeatureInstance
+ for iid in instanceIds:
+ fi = root.db.getRecord(FeatureInstance, iid)
+ if not fi:
+ logger.warning(
+ "MandateValidation: FeatureInstance %s referenced in graph not found", iid,
+ )
+ continue
+ fiMandateId = fi.get("mandateId") if isinstance(fi, dict) else getattr(fi, "mandateId", None)
+ if fiMandateId and fiMandateId != mandateId:
+ logger.warning(
+ "MandateValidation: FeatureInstance %s belongs to mandate %s, "
+ "but workflow mandate is %s — cross-mandate access",
+ iid, fiMandateId, mandateId,
+ )
+ except Exception as e:
+ logger.debug("MandateValidation: could not verify instances: %s", e)
+
+
def _substituteFeatureInstancePlaceholders(
graph: Dict[str, Any],
targetFeatureInstanceId: str,
@@ -675,6 +721,10 @@ async def executeGraph(
# Phase-5 Schicht-4: typed-ref envelopes are materialized FIRST so the
# subsequent connection-ref pass and validation see the canonical shape.
graph = materializeFeatureInstanceRefs(graph)
+
+ if mandateId:
+ _validateFeatureInstanceMandates(graph, mandateId)
+
graph = materializeConnectionRefs(graph)
graph = materializePrimaryTextHandover(graph)
graph = materializeRecommendedDataPickRef(graph)
diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py
index 9af626d4..ee1101e5 100644
--- a/modules/workflows/automation2/executors/actionNodeExecutor.py
+++ b/modules/workflows/automation2/executors/actionNodeExecutor.py
@@ -7,8 +7,6 @@
# ``documentListWire`` is applied at runtime in this executor via graphUtils.extract_wired_document_list.
-import base64
-import binascii
import json
import logging
import re
@@ -122,50 +120,7 @@ def _log_file_create_context_resolution(
)
-def _looks_like_ascii_base64_payload(s: str) -> bool:
- """Heuristic: ActionDocument binary payloads use standard ASCII base64; markdown/text uses other chars (#, *, -, …)."""
- t = "".join(s.split())
- if len(t) < 8:
- return False
- if not t.isascii():
- return False
- return bool(re.fullmatch(r"[A-Za-z0-9+/]+=*", t)) and len(t) % 4 == 0
-
-
-def coerceDocumentDataToBytes(raw: Any) -> Optional[bytes]:
- """Normalize documentData for DB file persistence.
-
- ActionDocument conventions (see methodFile.create): binary bodies are carried as ASCII
- base64 strings; plain markdown/text stays as Unicode. Do not UTF-8-encode a base64
- literal — that persists the ASCII of the encoding (file looks like base64 gibberish).
- """
- if raw is None:
- return None
- if isinstance(raw, bytes):
- return raw if len(raw) > 0 else None
- if isinstance(raw, bytearray):
- b = bytes(raw)
- return b if len(b) > 0 else None
- if isinstance(raw, memoryview):
- b = raw.tobytes()
- return b if len(b) > 0 else None
- if isinstance(raw, str):
- stripped = raw.strip()
- if not stripped:
- return None
- if _looks_like_ascii_base64_payload(stripped):
- try:
- decoded = base64.b64decode(stripped, validate=True)
- except (TypeError, binascii.Error, ValueError):
- try:
- decoded = base64.b64decode(stripped)
- except (binascii.Error, ValueError):
- decoded = b""
- if decoded:
- return decoded
- b = stripped.encode("utf-8")
- return b if len(b) > 0 else None
- return None
+from modules.shared.documentUtils import coerceDocumentDataToBytes # noqa: F401 — re-export shim
def _image_documents_from_docs_list(docs_list: list) -> list:
diff --git a/modules/workflows/automation2/executors/inputExecutor.py b/modules/workflows/automation2/executors/inputExecutor.py
index 4ccef725..aaf31ff1 100644
--- a/modules/workflows/automation2/executors/inputExecutor.py
+++ b/modules/workflows/automation2/executors/inputExecutor.py
@@ -4,29 +4,11 @@
import logging
from typing import Dict, Any
+from modules.datamodels.serviceExceptions import PauseForHumanTaskError, PauseForEmailWaitError # noqa: F401 — re-export shim
+
logger = logging.getLogger(__name__)
-class PauseForHumanTaskError(Exception):
- """Raised when execution must pause for a human task. Contains runId, taskId."""
-
- def __init__(self, runId: str, taskId: str, nodeId: str):
- self.runId = runId
- self.taskId = taskId
- self.nodeId = nodeId
- super().__init__(f"Pause for human task {taskId} (run {runId}, node {nodeId})")
-
-
-class PauseForEmailWaitError(Exception):
- """Raised when execution must pause waiting for a new email. Background poller will resume."""
-
- def __init__(self, runId: str, nodeId: str, waitConfig: Dict[str, Any]):
- self.runId = runId
- self.nodeId = nodeId
- self.waitConfig = waitConfig
- super().__init__(f"Pause for email wait (run {runId}, node {nodeId})")
-
-
class InputExecutor:
"""
Execute input/human nodes. Creates a HumanTask, pauses the run, and raises
diff --git a/modules/workflows/methods/methodContext/actions/setContext.py b/modules/workflows/methods/methodContext/actions/setContext.py
index 10f292b7..24e10fc8 100644
--- a/modules/workflows/methods/methodContext/actions/setContext.py
+++ b/modules/workflows/methods/methodContext/actions/setContext.py
@@ -22,7 +22,7 @@ import logging
from typing import Any, Dict, List, Optional, Tuple
from modules.datamodels.datamodelChat import ActionResult
-from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError
+from modules.datamodels.serviceExceptions import PauseForHumanTaskError
logger = logging.getLogger(__name__)
diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py
index cc5550ca..bb778c8f 100644
--- a/modules/workflows/methods/methodFile/actions/create.py
+++ b/modules/workflows/methods/methodFile/actions/create.py
@@ -13,7 +13,7 @@ import re
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
-from modules.workflows.automation2.executors.actionNodeExecutor import coerceDocumentDataToBytes
+from modules.shared.documentUtils import coerceDocumentDataToBytes
from modules.workflows.methods.methodAi._common import is_image_action_document_list
from modules.workflows.methods.methodContext.actions.extractContent import (
presentation_envelopes_to_document_json,
diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflows/scheduler/mainScheduler.py
index 9af9889f..11544015 100644
--- a/modules/workflows/scheduler/mainScheduler.py
+++ b/modules/workflows/scheduler/mainScheduler.py
@@ -93,9 +93,9 @@ class WorkflowScheduler:
activeWorkflowIds.add(workflowId)
cron = item.get("cron")
mandateId = item.get("mandateId")
- instanceId = item.get("featureInstanceId")
+ instanceId = item.get("featureInstanceId") or ""
- if not instanceId or not cron:
+ if not cron:
continue
jobId = f"{JOB_ID_PREFIX}{workflowId}"
From 9be2d8aab59fb850b4488894b401883e538df078 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 8 Jun 2026 10:31:17 +0200
Subject: [PATCH 06/16] 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
From ce612ffcfcb8cee9e8d2e84d1acb751583a583f6 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 8 Jun 2026 14:46:52 +0200
Subject: [PATCH 07/16] import referencing fixes
---
app.py | 2 +-
modules/aicore/aicoreModelRegistry.py | 25 ++++++++----
modules/datamodels/datamodelPortTypes.py | 21 ++++++++++
modules/demoConfigs/pwgDemo2026.py | 2 +-
.../features/redmine/workflows/__init__.py | 3 ++
.../workflows}/methodRedmine/__init__.py | 0
.../methodRedmine/actions/__init__.py | 0
.../methodRedmine/actions/_shared.py | 0
.../methodRedmine/actions/createTicket.py | 0
.../methodRedmine/actions/getStats.py | 0
.../methodRedmine/actions/listRelations.py | 0
.../methodRedmine/actions/listTickets.py | 0
.../methodRedmine/actions/readTicket.py | 0
.../methodRedmine/actions/runSync.py | 0
.../methodRedmine/actions/updateTicket.py | 0
.../workflows}/methodRedmine/methodRedmine.py | 0
.../workspace/routeFeatureWorkspace.py | 2 +-
.../interfaces/interfaceWorkflowAutomation.py | 6 +--
modules/nodeCatalog/__init__.py | 8 ++++
.../_workflowFileSchema.py | 0
.../editor => nodeCatalog}/nodeAdapter.py | 0
.../nodeDefinitions/__init__.py | 0
.../nodeDefinitions/ai.py | 4 +-
.../nodeDefinitions/clickup.py | 2 +-
.../nodeDefinitions/context.py | 4 +-
.../nodeDefinitions/contextPickerHelp.py | 8 ++--
.../nodeDefinitions/data.py | 2 +-
.../nodeDefinitions/email.py | 4 +-
.../nodeDefinitions/file.py | 6 +--
.../nodeDefinitions/flow.py | 10 ++---
.../nodeDefinitions/input.py | 2 +-
.../nodeDefinitions/redmine.py | 2 +-
.../nodeDefinitions/sharepoint.py | 2 +-
.../nodeDefinitions/triggers.py | 2 +-
.../nodeDefinitions/trustee.py | 2 +-
.../editor => nodeCatalog}/portTypes.py | 2 +-
modules/routes/routeWorkflowAutomation.py | 20 +++++++---
.../core/serviceStreaming/__init__.py | 3 +-
.../core/serviceStreaming/eventManager.py | 8 ----
.../serviceStreaming/mainServiceStreaming.py | 2 +-
.../serviceAgent/externalToolRegistry.py | 39 +++++++++++++++++++
.../services/serviceAgent/mainServiceAgent.py | 27 ++++++-------
modules/serviceHub/__init__.py | 7 ----
modules/system/i18nBootSync.py | 4 +-
.../agentTools.py} | 6 +--
.../editor/adapterValidator.py | 2 +-
.../editor/conditionOperators.py | 2 +-
.../workflowAutomation/editor/nodeRegistry.py | 8 ++--
.../workflowAutomation/editor/switchOutput.py | 2 +-
.../editor/upstreamPathsService.py | 4 +-
.../engine/executionEngine.py | 4 +-
.../engine/executors/actionNodeExecutor.py | 11 +++---
.../engine/executors/dataExecutor.py | 2 +-
.../engine/executors/flowExecutor.py | 2 +-
.../workflowAutomation/engine/graphUtils.py | 12 +++---
.../engine/pickNotPushMigration.py | 4 +-
.../helpers.py} | 5 ++-
.../mainWorkflowAutomation.py | 15 +++++++
modules/workflows/automation2/__init__.py | 13 -------
.../automation2/executors/__init__.py | 23 -----------
.../methods/_actionSignatureValidator.py | 4 +-
modules/workflows/methods/methodBase.py | 2 +-
.../methodContext/actions/setContext.py | 19 ++++-----
.../processing/core/actionExecutor.py | 2 +-
.../processing/core/messageCreator.py | 2 +-
.../workflows/processing/core/taskPlanner.py | 2 +-
.../processing/modes/modeAutomation.py | 2 +-
.../workflows/processing/modes/modeDynamic.py | 2 +-
.../processing/shared/parameterValidation.py | 2 +-
.../workflows/processing/shared/stateTools.py | 10 -----
.../workflows/processing/workflowProcessor.py | 2 +-
modules/workflows/scheduler/__init__.py | 11 ------
modules/workflows/workflowManager.py | 2 +-
.../script_migrate_feature_instance_refs.py | 2 +-
tests/eval/runTrusteeBenchmark.py | 2 +-
tests/functional/test01_ai_model_selection.py | 2 +-
tests/functional/test02_ai_models.py | 2 +-
tests/functional/test03_ai_operations.py | 2 +-
tests/functional/test04_ai_behavior.py | 2 +-
.../graphicalEditor/test_adapter_validator.py | 6 +--
...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_upstream_paths_and_graph_schema.py | 2 +-
.../test_action_signature_validator.py | 2 +-
.../test_trustee_schema_compliance.py | 4 +-
.../unit/nodeDefinitions/test_usesai_flag.py | 2 +-
.../serviceAgent/test_workflow_tools_crud.py | 2 +-
tests/unit/workflow/test_node_combinations.py | 4 +-
.../unit/workflow/test_phase3_context_node.py | 4 +-
.../workflow/test_phase4_workflow_nodes.py | 2 +-
tests/unit/workflow/test_phase5_highvol.py | 2 +-
.../workflow/test_switch_filtered_output.py | 2 +-
.../unit/workflow/test_workflowFileSchema.py | 2 +-
.../workflows/test_automation2_graphUtils.py | 2 +-
96 files changed, 249 insertions(+), 217 deletions(-)
create mode 100644 modules/features/redmine/workflows/__init__.py
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/__init__.py (100%)
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/__init__.py (100%)
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/_shared.py (100%)
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/createTicket.py (100%)
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/getStats.py (100%)
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/listRelations.py (100%)
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/listTickets.py (100%)
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/readTicket.py (100%)
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/runSync.py (100%)
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/updateTicket.py (100%)
rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/methodRedmine.py (100%)
create mode 100644 modules/nodeCatalog/__init__.py
rename modules/{workflowAutomation/editor => nodeCatalog}/_workflowFileSchema.py (100%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeAdapter.py (100%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/__init__.py (100%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/ai.py (99%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/clickup.py (99%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/context.py (98%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/contextPickerHelp.py (78%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/data.py (97%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/email.py (96%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/file.py (90%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/flow.py (97%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/input.py (98%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/redmine.py (98%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/sharepoint.py (99%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/triggers.py (96%)
rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/trustee.py (98%)
rename modules/{workflowAutomation/editor => nodeCatalog}/portTypes.py (99%)
delete mode 100644 modules/serviceCenter/core/serviceStreaming/eventManager.py
create mode 100644 modules/serviceCenter/services/serviceAgent/externalToolRegistry.py
delete mode 100644 modules/serviceHub/__init__.py
rename modules/{serviceCenter/services/serviceAgent/workflowTools.py => workflowAutomation/agentTools.py} (99%)
rename modules/{shared/workflowAutomationHelpers.py => workflowAutomation/helpers.py} (99%)
delete mode 100644 modules/workflows/automation2/__init__.py
delete mode 100644 modules/workflows/automation2/executors/__init__.py
delete mode 100644 modules/workflows/processing/shared/stateTools.py
delete mode 100644 modules/workflows/scheduler/__init__.py
diff --git a/app.py b/app.py
index 2ecf3ad5..185ea95d 100644
--- a/app.py
+++ b/app.py
@@ -552,7 +552,7 @@ async def lifespan(app: FastAPI):
# to finish (up to 120 s keepalive timeout) before the rest of
# the shutdown can proceed.
try:
- from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager as _getStreamingEM
+ from modules.shared.eventManager import get_event_manager as _getStreamingEM
_getStreamingEM().shutdown()
except Exception as e:
logger.warning(f"Streaming EventManager shutdown failed: {e}")
diff --git a/modules/aicore/aicoreModelRegistry.py b/modules/aicore/aicoreModelRegistry.py
index 813b3ac4..164f71f9 100644
--- a/modules/aicore/aicoreModelRegistry.py
+++ b/modules/aicore/aicoreModelRegistry.py
@@ -10,14 +10,16 @@ import importlib
import os
import time
import threading
-from typing import Dict, List, Optional, Any, Tuple
+from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING
from modules.datamodels.datamodelAi import AiModel
+from modules.datamodels.datamodelRbac import AccessRuleContext
from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelUam import User
-from modules.security.rbacHelpers import checkResourceAccess
-from modules.security.rbac import RbacClass
from modules.connectors.connectorDbPostgre import DatabaseConnector
+if TYPE_CHECKING:
+ from modules.security.rbac import RbacClass
+
logger = logging.getLogger(__name__)
# TODO TESTING: Override maxTokens for all models during testing
@@ -186,7 +188,7 @@ class ModelRegistry:
def getAvailableModels(
self,
currentUser: Optional[User] = None,
- rbacInstance: Optional[RbacClass] = None,
+ rbacInstance: Optional["RbacClass"] = None,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> List[AiModel]:
@@ -237,7 +239,7 @@ class ModelRegistry:
self,
models: List[AiModel],
currentUser: User,
- rbacInstance: RbacClass,
+ rbacInstance: "RbacClass",
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> List[AiModel]:
@@ -262,7 +264,7 @@ class ModelRegistry:
logger.debug(f"User {currentUser.username} does not have access to model {model.displayName} (connector: {model.connectorType})")
return filteredModels
- def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacClass] = None) -> Optional[AiModel]:
+ def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional["RbacClass"] = None) -> Optional[AiModel]:
"""Get a specific model by displayName, optionally checking RBAC permissions.
Args:
@@ -284,8 +286,15 @@ class ModelRegistry:
connectorResourcePath = f"ai.model.{model.connectorType}"
modelResourcePath = f"ai.model.{model.connectorType}.{model.displayName}"
- hasConnectorAccess = checkResourceAccess(rbacInstance, currentUser, connectorResourcePath)
- hasModelAccess = checkResourceAccess(rbacInstance, currentUser, modelResourcePath)
+ try:
+ connPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, connectorResourcePath)
+ modelPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, modelResourcePath)
+ hasConnectorAccess = connPerms.view if connPerms else False
+ hasModelAccess = modelPerms.view if modelPerms else False
+ except Exception as e:
+ logger.error(f"Error checking resource access for {modelResourcePath}: {e}")
+ hasConnectorAccess = False
+ hasModelAccess = False
if not (hasConnectorAccess or hasModelAccess):
logger.warning(f"User {currentUser.username} does not have access to model {displayName}")
diff --git a/modules/datamodels/datamodelPortTypes.py b/modules/datamodels/datamodelPortTypes.py
index 6d87c25e..1357af4f 100644
--- a/modules/datamodels/datamodelPortTypes.py
+++ b/modules/datamodels/datamodelPortTypes.py
@@ -549,3 +549,24 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PRIMITIVE_TYPES: frozenset = frozenset({
"str", "int", "bool", "float", "Any", "Dict", "List",
})
+
+
+def stripContainer(typeStr: str) -> List[str]:
+ """
+ Extract referenced type names from a PortField.type string.
+
+ Examples:
+ "str" -> ["str"]
+ "List[Document]" -> ["Document"]
+ "Dict[str,Any]" -> ["str", "Any"]
+ "ConnectionRef" -> ["ConnectionRef"]
+ "List[ProcessError]" -> ["ProcessError"]
+ """
+ s = (typeStr or "").strip()
+ if not s:
+ return []
+ if "[" in s and s.endswith("]"):
+ inner = s[s.index("[") + 1 : -1]
+ parts = [p.strip() for p in inner.split(",") if p.strip()]
+ return parts or [s]
+ return [s]
diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py
index c2c196af..efcd8c8a 100644
--- a/modules/demoConfigs/pwgDemo2026.py
+++ b/modules/demoConfigs/pwgDemo2026.py
@@ -561,7 +561,7 @@ class PwgDemo2026(BaseDemoConfig):
summary["errors"].append(f"WorkflowAutomation DB connection failed: {exc}")
return
- from modules.workflowAutomation.editor._workflowFileSchema import (
+ from modules.nodeCatalog._workflowFileSchema import (
envelopeToWorkflowData,
validateFileEnvelope,
)
diff --git a/modules/features/redmine/workflows/__init__.py b/modules/features/redmine/workflows/__init__.py
new file mode 100644
index 00000000..8c4ceb1a
--- /dev/null
+++ b/modules/features/redmine/workflows/__init__.py
@@ -0,0 +1,3 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Feature-owned workflow methods for Redmine."""
diff --git a/modules/workflows/methods/methodRedmine/__init__.py b/modules/features/redmine/workflows/methodRedmine/__init__.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/__init__.py
rename to modules/features/redmine/workflows/methodRedmine/__init__.py
diff --git a/modules/workflows/methods/methodRedmine/actions/__init__.py b/modules/features/redmine/workflows/methodRedmine/actions/__init__.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/actions/__init__.py
rename to modules/features/redmine/workflows/methodRedmine/actions/__init__.py
diff --git a/modules/workflows/methods/methodRedmine/actions/_shared.py b/modules/features/redmine/workflows/methodRedmine/actions/_shared.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/actions/_shared.py
rename to modules/features/redmine/workflows/methodRedmine/actions/_shared.py
diff --git a/modules/workflows/methods/methodRedmine/actions/createTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/createTicket.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/actions/createTicket.py
rename to modules/features/redmine/workflows/methodRedmine/actions/createTicket.py
diff --git a/modules/workflows/methods/methodRedmine/actions/getStats.py b/modules/features/redmine/workflows/methodRedmine/actions/getStats.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/actions/getStats.py
rename to modules/features/redmine/workflows/methodRedmine/actions/getStats.py
diff --git a/modules/workflows/methods/methodRedmine/actions/listRelations.py b/modules/features/redmine/workflows/methodRedmine/actions/listRelations.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/actions/listRelations.py
rename to modules/features/redmine/workflows/methodRedmine/actions/listRelations.py
diff --git a/modules/workflows/methods/methodRedmine/actions/listTickets.py b/modules/features/redmine/workflows/methodRedmine/actions/listTickets.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/actions/listTickets.py
rename to modules/features/redmine/workflows/methodRedmine/actions/listTickets.py
diff --git a/modules/workflows/methods/methodRedmine/actions/readTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/readTicket.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/actions/readTicket.py
rename to modules/features/redmine/workflows/methodRedmine/actions/readTicket.py
diff --git a/modules/workflows/methods/methodRedmine/actions/runSync.py b/modules/features/redmine/workflows/methodRedmine/actions/runSync.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/actions/runSync.py
rename to modules/features/redmine/workflows/methodRedmine/actions/runSync.py
diff --git a/modules/workflows/methods/methodRedmine/actions/updateTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/actions/updateTicket.py
rename to modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py
diff --git a/modules/workflows/methods/methodRedmine/methodRedmine.py b/modules/features/redmine/workflows/methodRedmine/methodRedmine.py
similarity index 100%
rename from modules/workflows/methods/methodRedmine/methodRedmine.py
rename to modules/features/redmine/workflows/methodRedmine/methodRedmine.py
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index d9fc3f4d..6d83e234 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -27,7 +27,7 @@ from modules.interfaces import interfaceDbChat, interfaceDbManagement
from modules.features.workspace import interfaceFeatureWorkspace
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
from modules.interfaces.interfaceAiObjects import AiObjects
-from modules.serviceCenter.core.serviceStreaming import get_event_manager
+from modules.shared.eventManager import get_event_manager
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit
from modules.shared.timeUtils import parseTimestamp
from modules.shared.i18nRegistry import apiRouteContext, resolveText
diff --git a/modules/interfaces/interfaceWorkflowAutomation.py b/modules/interfaces/interfaceWorkflowAutomation.py
index 6d192451..ba8fe6e7 100644
--- a/modules/interfaces/interfaceWorkflowAutomation.py
+++ b/modules/interfaces/interfaceWorkflowAutomation.py
@@ -692,7 +692,7 @@ class WorkflowAutomationObjects:
envelope) and can be JSON-serialized as-is. Returns ``None`` if the
workflow does not exist for this mandate.
"""
- from modules.workflowAutomation.editor._workflowFileSchema import buildFileFromWorkflow
+ from modules.nodeCatalog._workflowFileSchema import buildFileFromWorkflow
wf = self.getWorkflow(workflowId)
if not wf:
@@ -711,11 +711,11 @@ class WorkflowAutomationObjects:
``existingWorkflowId`` is given. Imports are always saved with
``active=False`` so operators can review before scheduling.
"""
- from modules.workflowAutomation.editor._workflowFileSchema import (
+ from modules.nodeCatalog._workflowFileSchema import (
envelopeToWorkflowData,
validateFileEnvelope,
)
- from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+ from modules.nodeCatalog.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)
diff --git a/modules/nodeCatalog/__init__.py b/modules/nodeCatalog/__init__.py
new file mode 100644
index 00000000..cbe6a49e
--- /dev/null
+++ b/modules/nodeCatalog/__init__.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2025 Patrick Motsch
+"""
+nodeCatalog (L2) — neutraler Node-Definitions-Container.
+
+Statische Node-Schemas, Port-Typen, Node-Adapter und das Workflow-File-Schema.
+Haengt NUR von shared (L0) + datamodels (L1) ab. Wird von workflowAutomation,
+serviceCenter, interfaces, system, routes und demoConfigs importiert.
+"""
diff --git a/modules/workflowAutomation/editor/_workflowFileSchema.py b/modules/nodeCatalog/_workflowFileSchema.py
similarity index 100%
rename from modules/workflowAutomation/editor/_workflowFileSchema.py
rename to modules/nodeCatalog/_workflowFileSchema.py
diff --git a/modules/workflowAutomation/editor/nodeAdapter.py b/modules/nodeCatalog/nodeAdapter.py
similarity index 100%
rename from modules/workflowAutomation/editor/nodeAdapter.py
rename to modules/nodeCatalog/nodeAdapter.py
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/__init__.py b/modules/nodeCatalog/nodeDefinitions/__init__.py
similarity index 100%
rename from modules/workflowAutomation/editor/nodeDefinitions/__init__.py
rename to modules/nodeCatalog/nodeDefinitions/__init__.py
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/ai.py b/modules/nodeCatalog/nodeDefinitions/ai.py
similarity index 99%
rename from modules/workflowAutomation/editor/nodeDefinitions/ai.py
rename to modules/nodeCatalog/nodeDefinitions/ai.py
index 37cf691f..8e0f081e 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/ai.py
+++ b/modules/nodeCatalog/nodeDefinitions/ai.py
@@ -3,10 +3,10 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import (
+from modules.nodeCatalog.nodeDefinitions.contextPickerHelp import (
CONTEXT_BUILDER_PARAM_DESCRIPTION,
)
-from modules.workflowAutomation.editor.nodeDefinitions.flow import (
+from modules.nodeCatalog.nodeDefinitions.flow import (
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
)
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/clickup.py b/modules/nodeCatalog/nodeDefinitions/clickup.py
similarity index 99%
rename from modules/workflowAutomation/editor/nodeDefinitions/clickup.py
rename to modules/nodeCatalog/nodeDefinitions/clickup.py
index 60c60bd5..1e330d29 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/clickup.py
+++ b/modules/nodeCatalog/nodeDefinitions/clickup.py
@@ -4,7 +4,7 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
+from modules.nodeCatalog.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
TASK_LIST_DATA_PICK_OPTIONS = [
{
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/context.py b/modules/nodeCatalog/nodeDefinitions/context.py
similarity index 98%
rename from modules/workflowAutomation/editor/nodeDefinitions/context.py
rename to modules/nodeCatalog/nodeDefinitions/context.py
index 839417e9..dc05fa40 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/context.py
+++ b/modules/nodeCatalog/nodeDefinitions/context.py
@@ -4,7 +4,7 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.flow import (
+from modules.nodeCatalog.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."
),
},
{
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py b/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py
similarity index 78%
rename from modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py
rename to modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py
index 55529951..116164c1 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py
+++ b/modules/nodeCatalog/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/workflowAutomation/editor/nodeDefinitions/data.py b/modules/nodeCatalog/nodeDefinitions/data.py
similarity index 97%
rename from modules/workflowAutomation/editor/nodeDefinitions/data.py
rename to modules/nodeCatalog/nodeDefinitions/data.py
index c8a4a3e5..a12ddeb6 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/data.py
+++ b/modules/nodeCatalog/nodeDefinitions/data.py
@@ -3,7 +3,7 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.ai import CONSOLIDATE_RESULT_DATA_PICK_OPTIONS
+from modules.nodeCatalog.nodeDefinitions.ai import CONSOLIDATE_RESULT_DATA_PICK_OPTIONS
AGGREGATE_RESULT_DATA_PICK_OPTIONS = [
{
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/email.py b/modules/nodeCatalog/nodeDefinitions/email.py
similarity index 96%
rename from modules/workflowAutomation/editor/nodeDefinitions/email.py
rename to modules/nodeCatalog/nodeDefinitions/email.py
index d5c7fe8c..a0503452 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/email.py
+++ b/modules/nodeCatalog/nodeDefinitions/email.py
@@ -3,10 +3,10 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import (
+from modules.nodeCatalog.nodeDefinitions.contextPickerHelp import (
CONTEXT_BUILDER_PARAM_DESCRIPTION,
)
-from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
+from modules.nodeCatalog.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
EMAIL_LIST_DATA_PICK_OPTIONS = [
{
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/file.py b/modules/nodeCatalog/nodeDefinitions/file.py
similarity index 90%
rename from modules/workflowAutomation/editor/nodeDefinitions/file.py
rename to modules/nodeCatalog/nodeDefinitions/file.py
index 88deb5ec..70c13a07 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/file.py
+++ b/modules/nodeCatalog/nodeDefinitions/file.py
@@ -3,10 +3,10 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import (
+from modules.nodeCatalog.nodeDefinitions.contextPickerHelp import (
CONTEXT_BUILDER_PARAM_DESCRIPTION,
)
-from modules.workflowAutomation.editor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS
+from modules.nodeCatalog.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": [
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/flow.py b/modules/nodeCatalog/nodeDefinitions/flow.py
similarity index 97%
rename from modules/workflowAutomation/editor/nodeDefinitions/flow.py
rename to modules/nodeCatalog/nodeDefinitions/flow.py
index fe1b1f30..94f517d9 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/flow.py
+++ b/modules/nodeCatalog/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,
},
{
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/input.py b/modules/nodeCatalog/nodeDefinitions/input.py
similarity index 98%
rename from modules/workflowAutomation/editor/nodeDefinitions/input.py
rename to modules/nodeCatalog/nodeDefinitions/input.py
index 5c152fdb..0f469880 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/input.py
+++ b/modules/nodeCatalog/nodeDefinitions/input.py
@@ -3,7 +3,7 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS
+from modules.nodeCatalog.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS
BOOL_RESULT_DATA_PICK_OPTIONS = [
{
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/redmine.py b/modules/nodeCatalog/nodeDefinitions/redmine.py
similarity index 98%
rename from modules/workflowAutomation/editor/nodeDefinitions/redmine.py
rename to modules/nodeCatalog/nodeDefinitions/redmine.py
index f20f2901..bf61cd26 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/redmine.py
+++ b/modules/nodeCatalog/nodeDefinitions/redmine.py
@@ -4,7 +4,7 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
+from modules.nodeCatalog.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/workflowAutomation/editor/nodeDefinitions/sharepoint.py b/modules/nodeCatalog/nodeDefinitions/sharepoint.py
similarity index 99%
rename from modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py
rename to modules/nodeCatalog/nodeDefinitions/sharepoint.py
index db48d8db..ae56f9a6 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py
+++ b/modules/nodeCatalog/nodeDefinitions/sharepoint.py
@@ -3,7 +3,7 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.ai import (
+from modules.nodeCatalog.nodeDefinitions.ai import (
ACTION_RESULT_DATA_PICK_OPTIONS,
DOCUMENT_LIST_DATA_PICK_OPTIONS,
)
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/triggers.py b/modules/nodeCatalog/nodeDefinitions/triggers.py
similarity index 96%
rename from modules/workflowAutomation/editor/nodeDefinitions/triggers.py
rename to modules/nodeCatalog/nodeDefinitions/triggers.py
index 0ae34ff2..deeae7a0 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/triggers.py
+++ b/modules/nodeCatalog/nodeDefinitions/triggers.py
@@ -3,7 +3,7 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
+from modules.nodeCatalog.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
TRIGGER_NODES = [
{
diff --git a/modules/workflowAutomation/editor/nodeDefinitions/trustee.py b/modules/nodeCatalog/nodeDefinitions/trustee.py
similarity index 98%
rename from modules/workflowAutomation/editor/nodeDefinitions/trustee.py
rename to modules/nodeCatalog/nodeDefinitions/trustee.py
index a8c390a8..b0521696 100644
--- a/modules/workflowAutomation/editor/nodeDefinitions/trustee.py
+++ b/modules/nodeCatalog/nodeDefinitions/trustee.py
@@ -3,7 +3,7 @@
from modules.shared.i18nRegistry import t
-from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
+from modules.nodeCatalog.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
# Typed FeatureInstance binding (replaces legacy `string, hidden`).
# - type uses the discriminator notation `FeatureInstanceRef[]` so the
diff --git a/modules/workflowAutomation/editor/portTypes.py b/modules/nodeCatalog/portTypes.py
similarity index 99%
rename from modules/workflowAutomation/editor/portTypes.py
rename to modules/nodeCatalog/portTypes.py
index 6246896e..aa8f4385 100644
--- a/modules/workflowAutomation/editor/portTypes.py
+++ b/modules/nodeCatalog/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.workflowAutomation.editor.nodeDefinitions.input import FORM_FIELD_TYPES
+ from modules.nodeCatalog.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/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py
index ee5d4ac1..81c009fb 100644
--- a/modules/routes/routeWorkflowAutomation.py
+++ b/modules/routes/routeWorkflowAutomation.py
@@ -12,22 +12,32 @@ RBAC model:
- isPlatformAdmin bypasses all checks
"""
+import asyncio
+import json
import logging
+import math
+import time
import uuid
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
+from fastapi.responses import JSONResponse, Response, StreamingResponse
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.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
from modules.datamodels.datamodelWorkflowAutomation import (
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
)
+from modules.dbHelpers.paginationHelpers import applyFiltersAndSort
+from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.i18nRegistry import apiRouteContext, resolveText
-from modules.shared.workflowAutomationHelpers import (
+from modules.workflowAutomation.helpers import (
_getWorkflowAutomationDb,
+ _getUserMandateIds,
+ _isUserMandateAdmin,
_validateWorkflowAccess,
_scopedWorkflowFilter,
_scopedRunFilter,
@@ -736,7 +746,7 @@ def _importWorkflow(
if not context.user:
raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required"))
- from modules.workflowAutomation.editor._workflowFileSchema import WorkflowFileSchemaError
+ from modules.nodeCatalog._workflowFileSchema import WorkflowFileSchemaError
mandateId = body.get("mandateId") if isinstance(body, dict) else None
userId = str(context.user.id)
@@ -799,7 +809,7 @@ def _exportWorkflow(
if not context.user:
raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required"))
- from modules.workflowAutomation.editor._workflowFileSchema import buildFileName
+ from modules.nodeCatalog._workflowFileSchema import buildFileName
db = _getWorkflowAutomationDb()
try:
@@ -909,7 +919,7 @@ def _getFeatureInstanceOptions(
targetMandateIds = userMandateIds if not context.isPlatformAdmin else []
if context.isPlatformAdmin:
try:
- from modules.datamodels.datamodelMandate import Mandate
+ from modules.datamodels.datamodelUam 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:
@@ -1038,7 +1048,7 @@ async def _getRunStream(
else:
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
- from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager
+ from modules.shared.eventManager import get_event_manager
sseEventManager = get_event_manager()
queueId = f"run-trace-{runId}"
sseEventManager.create_queue(queueId)
diff --git a/modules/serviceCenter/core/serviceStreaming/__init__.py b/modules/serviceCenter/core/serviceStreaming/__init__.py
index ae7f582b..18a34f4e 100644
--- a/modules/serviceCenter/core/serviceStreaming/__init__.py
+++ b/modules/serviceCenter/core/serviceStreaming/__init__.py
@@ -2,7 +2,6 @@
# All rights reserved.
"""Streaming core service for SSE event management."""
-from .eventManager import EventManager, get_event_manager
from .mainServiceStreaming import StreamingService
-__all__ = ["EventManager", "get_event_manager", "StreamingService"]
+__all__ = ["StreamingService"]
diff --git a/modules/serviceCenter/core/serviceStreaming/eventManager.py b/modules/serviceCenter/core/serviceStreaming/eventManager.py
deleted file mode 100644
index 823dbda1..00000000
--- a/modules/serviceCenter/core/serviceStreaming/eventManager.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (c) 2025 Patrick Motsch
-# All rights reserved.
-"""Re-export shim — canonical source is modules.shared.eventManager."""
-
-from modules.shared.eventManager import ( # noqa: F401
- EventManager,
- get_event_manager,
-)
diff --git a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py
index ee534706..c6c7ddf7 100644
--- a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py
+++ b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py
@@ -8,7 +8,7 @@ Core service - not requested by features directly.
import logging
from typing import Any, Callable
-from modules.serviceCenter.core.serviceStreaming.eventManager import EventManager, get_event_manager
+from modules.shared.eventManager import EventManager, get_event_manager
logger = logging.getLogger(__name__)
diff --git a/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py b/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py
new file mode 100644
index 00000000..cfa24a2b
--- /dev/null
+++ b/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py
@@ -0,0 +1,39 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""
+External agent-tool provider registry.
+
+Allows higher-layer components (e.g. ``workflowAutomation``) to register their
+agent tool definitions WITHOUT the agent service importing them. The agent
+reads registered definitions by toolbox id during toolbox activation.
+
+This inverts the dependency: ``workflowAutomation -> serviceCenter`` (push at
+boot), instead of ``serviceCenter -> workflowAutomation`` (pull). The agent
+never imports workflowAutomation.
+
+Tool definition dicts are the same shape consumed by
+``ToolRegistry.registerFromDefinition`` (``name``, ``description``,
+``parameters``, optional ``handler`` / ``readOnly`` / ``toolSet`` ...).
+"""
+
+import logging
+from typing import Dict, List, Any
+
+logger = logging.getLogger(__name__)
+
+_externalTools: Dict[str, List[Dict[str, Any]]] = {}
+
+
+def registerExternalTools(toolboxId: str, toolDefinitions: List[Dict[str, Any]]) -> None:
+ """Register agent tool definitions (with handlers) for a toolbox id."""
+ _externalTools[toolboxId] = list(toolDefinitions or [])
+ logger.info(
+ "Registered %d external agent tool(s) for toolbox '%s'",
+ len(_externalTools[toolboxId]),
+ toolboxId,
+ )
+
+
+def getExternalTools(toolboxId: str) -> List[Dict[str, Any]]:
+ """Return registered tool definitions for a toolbox id (empty if none)."""
+ return _externalTools.get(toolboxId, [])
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index 6620f219..e0c57496 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -420,20 +420,21 @@ class AgentService:
for tb in activeToolboxes:
activeToolNames.update(tb.tools)
+ from modules.serviceCenter.services.serviceAgent.externalToolRegistry import getExternalTools
+ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefinition
for tb in activeToolboxes:
- if tb.id == "workflow":
- try:
- from modules.serviceCenter.services.serviceAgent.workflowTools import getWorkflowToolDefinitions
- from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefinition
- wfDefs = getWorkflowToolDefinitions()
- for rawDef in wfDefs:
- handler = rawDef.get("handler")
- defFields = {k: v for k, v in rawDef.items() if k != "handler"}
- toolDef = ToolDefinition(**defFields)
- registry.registerFromDefinition(toolDef, handler)
- logger.info("Registered %d workflow tools from toolbox", len(wfDefs))
- except Exception as e:
- logger.warning("Could not register workflow tools: %s", e)
+ extDefs = getExternalTools(tb.id)
+ if not extDefs:
+ continue
+ try:
+ for rawDef in extDefs:
+ handler = rawDef.get("handler")
+ defFields = {k: v for k, v in rawDef.items() if k != "handler"}
+ toolDef = ToolDefinition(**defFields)
+ registry.registerFromDefinition(toolDef, handler)
+ logger.info("Registered %d external tool(s) for toolbox '%s'", len(extDefs), tb.id)
+ except Exception as e:
+ logger.warning("Could not register external tools for toolbox '%s': %s", tb.id, e)
inactiveToolNames = set()
for tb in tbRegistry.getAllToolboxes():
diff --git a/modules/serviceHub/__init__.py b/modules/serviceHub/__init__.py
deleted file mode 100644
index 14021394..00000000
--- a/modules/serviceHub/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# Re-export shim — canonical source: modules.serviceCenter.serviceHub
-from modules.serviceCenter.serviceHub import ( # noqa: F401
- PublicService,
- ServiceHub,
- Services,
- getInterface,
-)
diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py
index 3a32bee2..820376b1 100644
--- a/modules/system/i18nBootSync.py
+++ b/modules/system/i18nBootSync.py
@@ -242,7 +242,7 @@ def _registerNodeLabels():
added += 1
try:
- from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+ from modules.nodeCatalog.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")
@@ -265,7 +265,7 @@ def _registerNodeLabels():
pass
try:
- from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG
+ from modules.nodeCatalog.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/serviceCenter/services/serviceAgent/workflowTools.py b/modules/workflowAutomation/agentTools.py
similarity index 99%
rename from modules/serviceCenter/services/serviceAgent/workflowTools.py
rename to modules/workflowAutomation/agentTools.py
index c1d3bf1e..88ac3a05 100644
--- a/modules/serviceCenter/services/serviceAgent/workflowTools.py
+++ b/modules/workflowAutomation/agentTools.py
@@ -436,7 +436,7 @@ async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolR
"""
name = "listAvailableNodeTypes"
try:
- from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+ from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
nodeTypes = []
for n in STATIC_NODE_TYPES:
if not isinstance(n, dict):
@@ -462,7 +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")
- from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+ from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
target: Dict[str, Any] = {}
for n in STATIC_NODE_TYPES:
if isinstance(n, dict) and n.get("id") == nodeType:
@@ -875,7 +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")
- from modules.workflowAutomation.editor._workflowFileSchema import buildFileName
+ from modules.nodeCatalog._workflowFileSchema import buildFileName
return _ok(name, {
"fileName": buildFileName(envelope.get("label", "workflow")),
"envelope": envelope,
diff --git a/modules/workflowAutomation/editor/adapterValidator.py b/modules/workflowAutomation/editor/adapterValidator.py
index 77d16a91..6e430878 100644
--- a/modules/workflowAutomation/editor/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.workflowAutomation.editor.nodeAdapter import (
+from modules.nodeCatalog.nodeAdapter import (
NodeAdapter,
_adapterFromLegacyNode,
_isMethodBoundNode,
diff --git a/modules/workflowAutomation/editor/conditionOperators.py b/modules/workflowAutomation/editor/conditionOperators.py
index 3f67440f..e99defc1 100644
--- a/modules/workflowAutomation/editor/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.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
from modules.shared.i18nRegistry import resolveText, t
logger = logging.getLogger(__name__)
diff --git a/modules/workflowAutomation/editor/nodeRegistry.py b/modules/workflowAutomation/editor/nodeRegistry.py
index bbddd9f0..7a7ca1a9 100644
--- a/modules/workflowAutomation/editor/nodeRegistry.py
+++ b/modules/workflowAutomation/editor/nodeRegistry.py
@@ -9,10 +9,10 @@ import logging
from typing import Dict, List, Any, Optional
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.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.nodeDefinitions.input import FORM_FIELD_TYPES
+from modules.nodeCatalog.nodeAdapter import bindsActionFromLegacy
+from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText
logger = logging.getLogger(__name__)
diff --git a/modules/workflowAutomation/editor/switchOutput.py b/modules/workflowAutomation/editor/switchOutput.py
index e7cc830b..b70c5eb1 100644
--- a/modules/workflowAutomation/editor/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.workflowAutomation.editor.portTypes import unwrapTransit
+from modules.nodeCatalog.portTypes import unwrapTransit
_CONTEXT_FILTER_OPERATORS = frozenset({"contains_content"})
_BLOB_IMAGE_CHUNK_RE = re.compile(r"^\[image(?:\:([^\]]+))?\]$")
diff --git a/modules/workflowAutomation/editor/upstreamPathsService.py b/modules/workflowAutomation/editor/upstreamPathsService.py
index f3d2a6ab..3639b7b7 100644
--- a/modules/workflowAutomation/editor/upstreamPathsService.py
+++ b/modules/workflowAutomation/editor/upstreamPathsService.py
@@ -5,8 +5,8 @@ from __future__ import annotations
from typing import Any, Dict, List, Set
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.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.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/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py
index cbe572da..443de25d 100644
--- a/modules/workflowAutomation/engine/executionEngine.py
+++ b/modules/workflowAutomation/engine/executionEngine.py
@@ -29,8 +29,8 @@ from modules.workflowAutomation.engine.executors import (
PauseForHumanTaskError,
PauseForEmailWaitError,
)
-from modules.workflowAutomation.editor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit
-from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.portTypes import normalizeToSchema, wrapTransit, unwrapTransit
+from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
from modules.workflowAutomation.engine.runFileLogger import (
RunFileLogger,
diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
index 41c88a5d..dc88c7ab 100644
--- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
+++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
@@ -13,7 +13,7 @@ import re
import time
from typing import Any, Dict, Optional
-from modules.workflowAutomation.editor.portTypes import (
+from modules.nodeCatalog.portTypes import (
_normalizeError,
normalizeToSchema,
)
@@ -35,6 +35,7 @@ def _attach_unified_presentation_data(out: Dict[str, Any], *, node_def: Dict[str
"""Ensure ``out[\"data\"]`` carries ``context.extractContent.presentation.v1`` for ``file.create``."""
if node_def.get("skipUnifiedPresentation"):
return
+ node_type = node_def.get("type") or node_def.get("nodeType")
data = out.get("data")
if isinstance(data, dict) and data.get("kind") == PRESENTATION_KIND:
return
@@ -181,7 +182,7 @@ def _isUserConnectionId(val: Any) -> bool:
def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]:
"""Get node definition by type id."""
- from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+ from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
for node in STATIC_NODE_TYPES:
if node.get("id") == nodeType:
return node
@@ -304,7 +305,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.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG
+ from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG
schema = PORT_TYPE_CATALOG.get(outputSchema)
return bool(getattr(schema, "carriesConnectionProvenance", False))
@@ -430,7 +431,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.workflowAutomation.editor.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,7 +457,7 @@ 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.workflowAutomation.editor.switchOutput import unwrap_transit_for_port
src_map = (context.get("inputSources") or {}).get(nodeId) or {}
diff --git a/modules/workflowAutomation/engine/executors/dataExecutor.py b/modules/workflowAutomation/engine/executors/dataExecutor.py
index 3429e650..e22eda6f 100644
--- a/modules/workflowAutomation/engine/executors/dataExecutor.py
+++ b/modules/workflowAutomation/engine/executors/dataExecutor.py
@@ -4,7 +4,7 @@
import logging
from typing import Any, Dict
-from modules.workflowAutomation.editor.portTypes import unwrapTransit, wrapTransit
+from modules.nodeCatalog.portTypes import unwrapTransit, wrapTransit
logger = logging.getLogger(__name__)
diff --git a/modules/workflowAutomation/engine/executors/flowExecutor.py b/modules/workflowAutomation/engine/executors/flowExecutor.py
index f107a580..7a296204 100644
--- a/modules/workflowAutomation/engine/executors/flowExecutor.py
+++ b/modules/workflowAutomation/engine/executors/flowExecutor.py
@@ -6,7 +6,7 @@ from datetime import datetime
from typing import Any, Dict, List, Optional
from modules.workflowAutomation.editor.conditionOperators import apply_condition_operator, resolve_value_kind
-from modules.workflowAutomation.editor.portTypes import wrapTransit, unwrapTransit
+from modules.nodeCatalog.portTypes import wrapTransit, unwrapTransit
logger = logging.getLogger(__name__)
diff --git a/modules/workflowAutomation/engine/graphUtils.py b/modules/workflowAutomation/engine/graphUtils.py
index 946faafa..c6a4b5cd 100644
--- a/modules/workflowAutomation/engine/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.workflowAutomation.editor.portTypes import deriveFormPayloadSchemaFromParam
+ from modules.nodeCatalog.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.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
- from modules.workflowAutomation.editor.portTypes import resolve_output_schema_name
+ from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
+ from modules.nodeCatalog.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")}
@@ -481,7 +481,7 @@ def resolveParameterReferences(
)
if value.get("type") == "system":
variable = value.get("variable", "")
- from modules.workflowAutomation.editor.portTypes import resolveSystemVariable
+ from modules.nodeCatalog.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.workflowAutomation.editor.portTypes import (
+ from modules.nodeCatalog.portTypes import (
unwrapTransit,
_coerce_document_list_upload_fields,
_file_record_to_document,
diff --git a/modules/workflowAutomation/engine/pickNotPushMigration.py b/modules/workflowAutomation/engine/pickNotPushMigration.py
index 1b3d9249..78bd63c4 100644
--- a/modules/workflowAutomation/engine/pickNotPushMigration.py
+++ b/modules/workflowAutomation/engine/pickNotPushMigration.py
@@ -16,8 +16,8 @@ import copy
import logging
from typing import Any, Dict, List, Optional
-from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
-from modules.workflowAutomation.editor.portTypes import (
+from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.portTypes import (
PRIMARY_TEXT_HANDOVER_REF_PATH,
resolve_output_schema_name,
)
diff --git a/modules/shared/workflowAutomationHelpers.py b/modules/workflowAutomation/helpers.py
similarity index 99%
rename from modules/shared/workflowAutomationHelpers.py
rename to modules/workflowAutomation/helpers.py
index 4813c087..21471e6e 100644
--- a/modules/shared/workflowAutomationHelpers.py
+++ b/modules/workflowAutomation/helpers.py
@@ -202,8 +202,9 @@ def _validateWorkflowAccess(
if action == "execute":
targetInstanceId = workflow.get("targetFeatureInstanceId")
if targetInstanceId:
- from modules.interfaces.interfaceFeatureAccess import _hasFeatureAccess
- if _hasFeatureAccess(userId, targetInstanceId):
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ access = getRootInterface().getFeatureAccess(userId, targetInstanceId)
+ if access and access.get("enabled"):
return
adminMandateIds = _getAdminMandateIds(userId, [wfMandateId])
diff --git a/modules/workflowAutomation/mainWorkflowAutomation.py b/modules/workflowAutomation/mainWorkflowAutomation.py
index 754d77b5..20c1d4fb 100644
--- a/modules/workflowAutomation/mainWorkflowAutomation.py
+++ b/modules/workflowAutomation/mainWorkflowAutomation.py
@@ -226,9 +226,24 @@ def _migrateRbacNamespace() -> None:
logger.warning(f"RBAC namespace migration failed (non-critical): {e}")
+def _registerAgentTools() -> None:
+ """Push workflow agent tools into the agent's external tool registry.
+
+ Inverts the dependency: workflowAutomation -> serviceCenter (push at boot),
+ so the agent service never imports workflowAutomation to obtain its tools.
+ """
+ try:
+ from modules.serviceCenter.services.serviceAgent.externalToolRegistry import registerExternalTools
+ from modules.workflowAutomation.agentTools import getWorkflowToolDefinitions, TOOLBOX_ID
+ registerExternalTools(TOOLBOX_ID, getWorkflowToolDefinitions())
+ except Exception as e:
+ logger.warning(f"Could not register workflow agent tools (non-critical): {e}")
+
+
def onBootstrap() -> None:
"""Seed system workflow templates and sync feature template workflows on boot."""
_migrateRbacNamespace()
+ _registerAgentTools()
from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow
from modules.connectors.connectorDbPostgre import DatabaseConnector
diff --git a/modules/workflows/automation2/__init__.py b/modules/workflows/automation2/__init__.py
deleted file mode 100644
index 28ce2eea..00000000
--- a/modules/workflows/automation2/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# Copyright (c) 2025 Patrick Motsch
-# 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
deleted file mode 100644
index 1c2b18d4..00000000
--- a/modules/workflows/automation2/executors/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Copyright (c) 2025 Patrick Motsch
-# Re-export shim: executors moved to modules.workflowAutomation.engine.executors
-# This file preserves backwards compatibility for existing imports.
-
-from modules.workflowAutomation.engine.executors import ( # noqa: F401
- TriggerExecutor,
- FlowExecutor,
- ActionNodeExecutor,
- InputExecutor,
- DataExecutor,
- PauseForHumanTaskError,
- PauseForEmailWaitError,
-)
-
-__all__ = [
- "TriggerExecutor",
- "FlowExecutor",
- "ActionNodeExecutor",
- "InputExecutor",
- "DataExecutor",
- "PauseForHumanTaskError",
- "PauseForEmailWaitError",
-]
diff --git a/modules/workflows/methods/_actionSignatureValidator.py b/modules/workflows/methods/_actionSignatureValidator.py
index aeeb49c1..ce43ee7b 100644
--- a/modules/workflows/methods/_actionSignatureValidator.py
+++ b/modules/workflows/methods/_actionSignatureValidator.py
@@ -25,10 +25,10 @@ from modules.datamodels.datamodelWorkflowActions import (
WorkflowActionDefinition,
WorkflowActionParameter,
)
-from modules.workflowAutomation.editor.portTypes import (
+from modules.datamodels.datamodelPortTypes import (
PORT_TYPE_CATALOG,
PRIMITIVE_TYPES,
- _stripContainer,
+ stripContainer as _stripContainer,
)
logger = logging.getLogger(__name__)
diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py
index 5ab10077..abc7b9c0 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.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG
+ from modules.datamodels.datamodelPortTypes 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 62435e38..58925f9e 100644
--- a/modules/workflows/methods/methodContext/actions/setContext.py
+++ b/modules/workflows/methods/methodContext/actions/setContext.py
@@ -320,18 +320,15 @@ 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.workflowAutomation.engine.runFileLogger import merge_persisted_run_context
- _pause_ctx = merge_persisted_run_context(
- iface,
- run_id,
- {
- "connectionMap": run_context.get("connectionMap"),
- "inputSources": run_context.get("inputSources"),
- "orderedNodeIds": ordered_ids,
- "pauseReason": "contextAssignment",
- },
- )
+ prev_ctx = dict((iface.getRun(run_id) or {}).get("context") or {})
+ _pause_ctx = {
+ **prev_ctx,
+ "connectionMap": run_context.get("connectionMap"),
+ "inputSources": run_context.get("inputSources"),
+ "orderedNodeIds": ordered_ids,
+ "pauseReason": "contextAssignment",
+ }
iface.updateRun(
run_id,
status="paused",
diff --git a/modules/workflows/processing/core/actionExecutor.py b/modules/workflows/processing/core/actionExecutor.py
index 1a162922..3156aa4b 100644
--- a/modules/workflows/processing/core/actionExecutor.py
+++ b/modules/workflows/processing/core/actionExecutor.py
@@ -9,7 +9,7 @@ from typing import Dict, Any, List
from modules.datamodels.datamodelChat import ActionResult, ActionItem, TaskStep
from modules.datamodels.datamodelChat import ChatWorkflow
from modules.workflows.processing.shared.methodDiscovery import methods
-from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
+from modules.shared.workflowState import checkWorkflowStopped
from modules.workflows.processing.shared.parameterValidation import (
InvalidActionParameterError, validateAndCoerceParameters,
)
diff --git a/modules/workflows/processing/core/messageCreator.py b/modules/workflows/processing/core/messageCreator.py
index cb8e344f..00aebc20 100644
--- a/modules/workflows/processing/core/messageCreator.py
+++ b/modules/workflows/processing/core/messageCreator.py
@@ -8,7 +8,7 @@ import re
from typing import Dict, Any, Optional, List
from modules.datamodels.datamodelChat import TaskPlan, TaskStep, ActionResult, ReviewResult
from modules.datamodels.datamodelChat import ChatWorkflow
-from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
+from modules.shared.workflowState import checkWorkflowStopped
logger = logging.getLogger(__name__)
diff --git a/modules/workflows/processing/core/taskPlanner.py b/modules/workflows/processing/core/taskPlanner.py
index 233488fe..8401c2a3 100644
--- a/modules/workflows/processing/core/taskPlanner.py
+++ b/modules/workflows/processing/core/taskPlanner.py
@@ -10,7 +10,7 @@ from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan, Wo
from modules.workflows.processing.shared.promptGenerationTaskplan import (
generateTaskPlanningPrompt
)
-from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
+from modules.shared.workflowState import checkWorkflowStopped
logger = logging.getLogger(__name__)
diff --git a/modules/workflows/processing/modes/modeAutomation.py b/modules/workflows/processing/modes/modeAutomation.py
index f48d509e..229bed5b 100644
--- a/modules/workflows/processing/modes/modeAutomation.py
+++ b/modules/workflows/processing/modes/modeAutomation.py
@@ -13,7 +13,7 @@ from modules.datamodels.datamodelChat import (
)
from modules.datamodels.datamodelChat import ChatWorkflow
from modules.workflows.processing.modes.modeBase import BaseMode
-from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
+from modules.shared.workflowState import checkWorkflowStopped
from modules.shared.timeUtils import parseTimestamp
logger = logging.getLogger(__name__)
diff --git a/modules/workflows/processing/modes/modeDynamic.py b/modules/workflows/processing/modes/modeDynamic.py
index b31568a2..045835fa 100644
--- a/modules/workflows/processing/modes/modeDynamic.py
+++ b/modules/workflows/processing/modes/modeDynamic.py
@@ -15,7 +15,7 @@ from modules.datamodels.datamodelChat import (
)
from modules.datamodels.datamodelChat import ChatWorkflow
from modules.workflows.processing.modes.modeBase import BaseMode
-from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
+from modules.shared.workflowState import checkWorkflowStopped
from modules.shared.timeUtils import parseTimestamp
from modules.workflows.processing.shared.executionState import TaskExecutionState, shouldContinue
from modules.workflows.processing.shared.promptGenerationActionsDynamic import (
diff --git a/modules/workflows/processing/shared/parameterValidation.py b/modules/workflows/processing/shared/parameterValidation.py
index ea182212..f8045b28 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.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG
+ from modules.datamodels.datamodelPortTypes import PORT_TYPE_CATALOG
schema = PORT_TYPE_CATALOG.get(typeStr)
if schema is None:
return False
diff --git a/modules/workflows/processing/shared/stateTools.py b/modules/workflows/processing/shared/stateTools.py
deleted file mode 100644
index c1614b69..00000000
--- a/modules/workflows/processing/shared/stateTools.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# Copyright (c) 2025 Patrick Motsch
-# All rights reserved.
-"""
-State Tools
-Re-exports from modules.shared.workflowState for backward compatibility.
-"""
-
-from modules.shared.workflowState import checkWorkflowStopped, WorkflowStoppedException
-
-__all__ = ["checkWorkflowStopped", "WorkflowStoppedException"]
diff --git a/modules/workflows/processing/workflowProcessor.py b/modules/workflows/processing/workflowProcessor.py
index 7f63fc62..d6fa00f0 100644
--- a/modules/workflows/processing/workflowProcessor.py
+++ b/modules/workflows/processing/workflowProcessor.py
@@ -14,7 +14,7 @@ from modules.datamodels.datamodelChat import ChatWorkflow, WorkflowModeEnum
from modules.workflows.processing.modes.modeBase import BaseMode
from modules.workflows.processing.modes.modeDynamic import DynamicMode
from modules.workflows.processing.modes.modeAutomation import AutomationMode
-from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
+from modules.shared.workflowState import checkWorkflowStopped
from modules.datamodels.datamodelAi import OperationTypeEnum, PriorityEnum, ProcessingModeEnum, AiCallOptions, AiCallRequest
from modules.shared.jsonUtils import extractJsonString, repairBrokenJson, parseJsonWithModel
from modules.datamodels.datamodelWorkflow import UnderstandingResult
diff --git a/modules/workflows/scheduler/__init__.py b/modules/workflows/scheduler/__init__.py
deleted file mode 100644
index 4e814ab5..00000000
--- a/modules/workflows/scheduler/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# Copyright (c) 2025 Patrick Motsch
-# 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/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py
index 379283b8..e983a139 100644
--- a/modules/workflows/workflowManager.py
+++ b/modules/workflows/workflowManager.py
@@ -14,7 +14,7 @@ from modules.datamodels.datamodelChat import (
)
from modules.datamodels.datamodelChat import TaskContext
from modules.workflows.processing.workflowProcessor import WorkflowProcessor
-from modules.workflows.processing.shared.stateTools import WorkflowStoppedException, checkWorkflowStopped
+from modules.shared.workflowState import WorkflowStoppedException, checkWorkflowStopped
logger = logging.getLogger(__name__)
diff --git a/scripts/script_migrate_feature_instance_refs.py b/scripts/script_migrate_feature_instance_refs.py
index 40f723c1..8af55a6c 100644
--- a/scripts/script_migrate_feature_instance_refs.py
+++ b/scripts/script_migrate_feature_instance_refs.py
@@ -56,7 +56,7 @@ import psycopg2 # noqa: E402
from psycopg2.extras import Json, RealDictCursor # noqa: E402
from modules.shared.configuration import APP_CONFIG # noqa: E402
-from modules.workflows.automation2.featureInstanceRefMigration import ( # noqa: E402
+from modules.workflowAutomation.engine.featureInstanceRefMigration import ( # noqa: E402
materializeFeatureInstanceRefs,
)
diff --git a/tests/eval/runTrusteeBenchmark.py b/tests/eval/runTrusteeBenchmark.py
index 3f298173..749bf996 100644
--- a/tests/eval/runTrusteeBenchmark.py
+++ b/tests/eval/runTrusteeBenchmark.py
@@ -415,7 +415,7 @@ def _bootstrapServices() -> Tuple[Any, str, str]:
"""
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import Mandate
- from modules.serviceHub import getInterface as getServices
+ from modules.serviceCenter.serviceHub import getInterface as getServices
rootInterface = getRootInterface()
user = rootInterface.currentUser
diff --git a/tests/functional/test01_ai_model_selection.py b/tests/functional/test01_ai_model_selection.py
index c6a588c9..7c69b927 100644
--- a/tests/functional/test01_ai_model_selection.py
+++ b/tests/functional/test01_ai_model_selection.py
@@ -19,7 +19,7 @@ _gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".
if _gateway_path not in sys.path:
sys.path.insert(0, _gateway_path)
-from modules.serviceHub import getInterface as getServices
+from modules.serviceCenter.serviceHub import getInterface as getServices
from modules.datamodels.datamodelAi import (
AiCallOptions,
AiCallRequest,
diff --git a/tests/functional/test02_ai_models.py b/tests/functional/test02_ai_models.py
index 0a0948cf..32aeed80 100644
--- a/tests/functional/test02_ai_models.py
+++ b/tests/functional/test02_ai_models.py
@@ -32,7 +32,7 @@ if _gateway_path not in sys.path:
sys.path.insert(0, _gateway_path)
# Import the service initialization
-from modules.serviceHub import getInterface as getServices
+from modules.serviceCenter.serviceHub import getInterface as getServices
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum
from modules.datamodels.datamodelUam import User
diff --git a/tests/functional/test03_ai_operations.py b/tests/functional/test03_ai_operations.py
index 4df53c5d..835078f0 100644
--- a/tests/functional/test03_ai_operations.py
+++ b/tests/functional/test03_ai_operations.py
@@ -101,7 +101,7 @@ class MethodAiOperationsTester:
interfaceDbChat = interfaceDbChat.getInterface(self.testUser)
# Import and initialize services
- from modules.serviceHub import getInterface as getServices
+ from modules.serviceCenter.serviceHub import getInterface as getServices
# Get services first
self.services = getServices(self.testUser, None)
diff --git a/tests/functional/test04_ai_behavior.py b/tests/functional/test04_ai_behavior.py
index 953b52a3..7845733a 100644
--- a/tests/functional/test04_ai_behavior.py
+++ b/tests/functional/test04_ai_behavior.py
@@ -17,7 +17,7 @@ if _gateway_path not in sys.path:
sys.path.insert(0, _gateway_path)
# Import the service initialization
-from modules.serviceHub import getInterface as getServices
+from modules.serviceCenter.serviceHub import getInterface as getServices
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelWorkflow import AiResponse
diff --git a/tests/unit/graphicalEditor/test_adapter_validator.py b/tests/unit/graphicalEditor/test_adapter_validator.py
index 605251c6..ad507daa 100644
--- a/tests/unit/graphicalEditor/test_adapter_validator.py
+++ b/tests/unit/graphicalEditor/test_adapter_validator.py
@@ -34,7 +34,7 @@ from modules.workflowAutomation.editor.adapterValidator import (
_validateAdapterAgainstAction,
_validateAllAdapters,
)
-from modules.workflowAutomation.editor.nodeAdapter import (
+from modules.nodeCatalog.nodeAdapter import (
NodeAdapter,
UserParamMapping,
)
@@ -254,7 +254,7 @@ def _ensureOptionalDeps():
_LIVE_METHODS = [
("modules.features.trustee.workflows.methodTrustee.methodTrustee", "MethodTrustee", "trustee"),
- ("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine", "redmine"),
+ ("modules.features.redmine.workflows.methodRedmine.methodRedmine", "MethodRedmine", "redmine"),
("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint", "sharepoint"),
("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook", "outlook"),
("modules.workflows.methods.methodAi.methodAi", "MethodAi", "ai"),
@@ -334,7 +334,7 @@ def test_staticNodesHaveNoDriftAgainstLiveMethods():
History: wiki/c-work/4-done/2026-04-adapter-drift-cleanup.md
"""
- from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+ from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
instances = _instantiateLiveMethods()
if not instances:
diff --git a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py
index 525faa4a..e81a0a4f 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.workflowAutomation.editor.nodeDefinitions.redmine import REDMINE_NODES
-from modules.workflowAutomation.editor.nodeDefinitions.trustee import TRUSTEE_NODES
+from modules.nodeCatalog.nodeDefinitions.redmine import REDMINE_NODES
+from modules.nodeCatalog.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 3c18f438..634f76d2 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.workflowAutomation.editor.nodeAdapter import (
+from modules.nodeCatalog.nodeAdapter import (
NodeAdapter,
UserParamMapping,
_adapterFromLegacyNode,
diff --git a/tests/unit/graphicalEditor/test_portTypes_catalog.py b/tests/unit/graphicalEditor/test_portTypes_catalog.py
index 0506be27..9e97d475 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.workflowAutomation.editor.portTypes import (
+from modules.nodeCatalog.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 7884109e..cd32e461 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.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, _defaultForType
+from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG, _defaultForType
def test_connection_ref_in_catalog():
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 8e64367e..6c6ff2cc 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.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
+from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
def test_compute_upstream_paths_includes_form_dynamic_fields():
diff --git a/tests/unit/methods/test_action_signature_validator.py b/tests/unit/methods/test_action_signature_validator.py
index fa4aa71f..a959989e 100644
--- a/tests/unit/methods/test_action_signature_validator.py
+++ b/tests/unit/methods/test_action_signature_validator.py
@@ -256,7 +256,7 @@ def _instantiateMethod(methodCls):
@pytest.mark.parametrize("modulePath,className", [
("modules.features.trustee.workflows.methodTrustee.methodTrustee", "MethodTrustee"),
- ("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine"),
+ ("modules.features.redmine.workflows.methodRedmine.methodRedmine", "MethodRedmine"),
("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint"),
("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook"),
("modules.workflows.methods.methodAi.methodAi", "MethodAi"),
diff --git a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py
index 36038ee1..060d04a6 100644
--- a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py
+++ b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py
@@ -20,8 +20,8 @@ Verifies that:
import inspect
-from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
-from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG
+from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG
from modules.workflowAutomation.engine.executors import actionNodeExecutor as _actionExec
from modules.workflowAutomation.engine.graphUtils import validateGraph
diff --git a/tests/unit/nodeDefinitions/test_usesai_flag.py b/tests/unit/nodeDefinitions/test_usesai_flag.py
index bf578fd0..caf07960 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.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.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 b578b1de..41e56ab6 100644
--- a/tests/unit/serviceAgent/test_workflow_tools_crud.py
+++ b/tests/unit/serviceAgent/test_workflow_tools_crud.py
@@ -22,7 +22,7 @@ from typing import Any, Dict, Optional
import pytest
-from modules.serviceCenter.services.serviceAgent import workflowTools
+from modules.workflowAutomation import agentTools as workflowTools
from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult
diff --git a/tests/unit/workflow/test_node_combinations.py b/tests/unit/workflow/test_node_combinations.py
index 15159048..b4857a14 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.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
-from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG
+from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
build_presentation_envelope_from_plain_text,
diff --git a/tests/unit/workflow/test_phase3_context_node.py b/tests/unit/workflow/test_phase3_context_node.py
index 49500bc2..5f113d5e 100644
--- a/tests/unit/workflow/test_phase3_context_node.py
+++ b/tests/unit/workflow/test_phase3_context_node.py
@@ -1,8 +1,8 @@
# Tests for Phase 3: context.extractContent node, port types, executor dispatch.
import pytest
-from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
-from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG
+from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG
from modules.workflowAutomation.engine.udmUpstreamShapes import (
_coerceConsolidateResultInput,
_coerceUdmDocumentInput,
diff --git a/tests/unit/workflow/test_phase4_workflow_nodes.py b/tests/unit/workflow/test_phase4_workflow_nodes.py
index 24a29d1f..3ca0792d 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.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
class TestNodeDefinitions:
diff --git a/tests/unit/workflow/test_phase5_highvol.py b/tests/unit/workflow/test_phase5_highvol.py
index 45079fb4..44c51d76 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.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
def test_loop_concurrency_param_default_1():
diff --git a/tests/unit/workflow/test_switch_filtered_output.py b/tests/unit/workflow/test_switch_filtered_output.py
index 334a8e81..ee9271d9 100644
--- a/tests/unit/workflow/test_switch_filtered_output.py
+++ b/tests/unit/workflow/test_switch_filtered_output.py
@@ -3,7 +3,7 @@
import pytest
-from modules.workflowAutomation.editor.portTypes import unwrapTransit, wrapTransit
+from modules.nodeCatalog.portTypes import unwrapTransit, wrapTransit
from modules.workflowAutomation.editor.switchOutput import (
build_switch_branch_payload,
build_switch_combined_output,
diff --git a/tests/unit/workflow/test_workflowFileSchema.py b/tests/unit/workflow/test_workflowFileSchema.py
index e7109cbc..3eb0fb2c 100644
--- a/tests/unit/workflow/test_workflowFileSchema.py
+++ b/tests/unit/workflow/test_workflowFileSchema.py
@@ -4,7 +4,7 @@
import pytest
-from modules.workflowAutomation.editor._workflowFileSchema import (
+from modules.nodeCatalog._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 0ee29412..179857c1 100644
--- a/tests/unit/workflows/test_automation2_graphUtils.py
+++ b/tests/unit/workflows/test_automation2_graphUtils.py
@@ -38,7 +38,7 @@ class TestValidateGraphStartNode:
def test_switch_second_output_to_ai_prompt_ok(self):
- from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
+ from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
node_type_ids = {n["id"] for n in STATIC_NODE_TYPES}
graph = {
From e0caad0a75a6ace5e6dfe01ef8549f9f3db918c1 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 8 Jun 2026 20:45:51 +0200
Subject: [PATCH 08/16] import fixes
---
modules/auth/jwtService.py | 2 +-
.../commcoach/routeFeatureCommcoach.py | 12 +-
modules/routes/routeRealEstate.py | 2339 -----------------
modules/routes/routeRealEstateScraping.py | 881 -------
.../services/serviceAi/subDocumentIntents.py | 2 +-
.../serviceAi/subJsonResponseHandling.py | 12 +-
.../renderers/rendererPptx.py | 5 +-
.../engine/executors/actionNodeExecutor.py | 1 -
.../engine/executors/flowExecutor.py | 4 +-
9 files changed, 21 insertions(+), 3237 deletions(-)
delete mode 100644 modules/routes/routeRealEstate.py
delete mode 100644 modules/routes/routeRealEstateScraping.py
diff --git a/modules/auth/jwtService.py b/modules/auth/jwtService.py
index 6ea4535d..04071053 100644
--- a/modules/auth/jwtService.py
+++ b/modules/auth/jwtService.py
@@ -5,7 +5,7 @@ JWT Service
Centralizes local JWT creation and cookie helpers.
"""
-from datetime import timedelta
+from datetime import datetime, timedelta
from typing import Optional, Tuple
from fastapi import Response
from jose import jwt
diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py
index a60db504..c7759900 100644
--- a/modules/features/commcoach/routeFeatureCommcoach.py
+++ b/modules/features/commcoach/routeFeatureCommcoach.py
@@ -14,7 +14,7 @@ import uuid
from typing import Optional
-from fastapi import APIRouter, HTTPException, Depends, Request, WebSocket, WebSocketDisconnect, Query
+from fastapi import APIRouter, HTTPException, Depends, Request, Query
from fastapi.responses import StreamingResponse, Response
from modules.auth import limiter, getRequestContext, RequestContext
@@ -27,13 +27,13 @@ from .datamodelCommcoach import (
TrainingModule, TrainingModuleStatus, CoachingSession, CoachingSessionStatus,
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
CoachingTask, CoachingTaskStatus,
- CoachingPersona, CoachingBadge, ModulePersonaMapping,
+ CoachingPersona,
CreateModuleRequest, UpdateModuleRequest,
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
UpdateProfileRequest,
- StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, SetModulePersonasRequest,
+ CreatePersonaRequest, UpdatePersonaRequest, SetModulePersonasRequest,
)
-from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
+from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureCommcoach")
logger = logging.getLogger(__name__)
@@ -104,7 +104,7 @@ async def listModules(
context: RequestContext = Depends(getRequestContext),
):
"""List all training modules for the current user."""
- mandateId = _validateInstanceAccess(instanceId, context)
+ _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
modules = interface.getModules(instanceId, userId, includeArchived=includeArchived)
@@ -349,7 +349,7 @@ async def startSession(
yield f"data: {json.dumps({'type': 'ttsAudio', 'data': {'audio': audioB64, 'format': 'mp3'}})}\n\n"
else:
errorDetail = ttsResult.get("error", "Text-to-Speech failed")
- yield f"data: {json.dumps({'type': 'error', 'data': {'message': _buildTtsConfigErrorMessage(language, voiceName, errorDetail), 'detail': errorDetail, 'ttsLanguage': language, 'ttsVoice': voiceName}})}\n\n"
+ yield f"data: {json.dumps({'type': 'error', 'data': {'message': buildTtsConfigErrorMessage(language, voiceName, errorDetail), 'detail': errorDetail, 'ttsLanguage': language, 'ttsVoice': voiceName}})}\n\n"
except Exception as e:
logger.warning(f"TTS failed for resumed session: {e}")
yield f"data: {json.dumps({'type': 'error', 'data': {'message': 'Die konfigurierte Stimme für diese Sprache ist ungültig oder nicht verfügbar. Bitte passe sie unter Einstellungen > Stimme & Sprache an.', 'detail': str(e)}})}\n\n"
diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py
deleted file mode 100644
index 81550de2..00000000
--- a/modules/routes/routeRealEstate.py
+++ /dev/null
@@ -1,2339 +0,0 @@
-"""
-Real Estate routes for the backend API.
-Implements stateless endpoints for real estate database operations with AI-powered natural language processing.
-"""
-
-import logging
-import json
-import re
-import requests
-import aiohttp
-import asyncio
-import ssl
-from urllib.parse import urljoin, urlparse
-from typing import Optional, Dict, Any, List, Union
-from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status
-
-# Import auth modules
-from modules.auth import limiter, getCurrentUser
-
-# Import models
-from modules.datamodels.datamodelPagination import (
- PaginationParams,
- PaginatedResponse,
- PaginationMetadata,
- normalize_pagination_dict,
-)
-from modules.interfaces.interfaceDbApp import getRootInterface
-from modules.interfaces.interfaceFeatures import getFeatureInterface
-from modules.features.realEstate.datamodelFeatureRealEstate import (
- Projekt,
- Parzelle,
- Dokument,
- Gemeinde,
- Kanton,
- Land,
- Kontext,
- StatusProzess,
- DokumentTyp,
-)
-
-# Import interfaces
-from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
-from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
-
-# Import feature logic for AI-powered commands
-from modules.features.realEstate.mainRealEstate import (
- processNaturalLanguageCommand,
- create_project_with_parcel_data,
- extract_bzo_information,
-)
-
-# Import Swiss Topo MapServer connector for testing
-from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
-from modules.connectors.connectorOerebWfs import OerebWfsConnector
-
-# Import Tavily connector for BZO document search
-from modules.aicore.aicorePluginTavily import AiTavily
-
-# Import helper functions from scraping route
-from modules.routes.routeRealEstateScraping import (
- _get_language_from_kanton,
- _get_bzo_search_query,
-)
-
-# Import attribute utilities for model schema
-from modules.shared.attributeUtils import getModelAttributeDefinitions
-from modules.shared.i18nRegistry import apiRouteContext
-routeApiMsg = apiRouteContext("routeRealEstate")
-
-# Configure logger
-logger = logging.getLogger(__name__)
-
-# Create router for real estate endpoints
-router = APIRouter(
- prefix="/api/realestate",
- tags=["Real Estate"],
- responses={
- 404: {"description": "Not found"},
- 400: {"description": "Bad request"},
- 401: {"description": "Unauthorized"},
- 403: {"description": "Forbidden"},
- 500: {"description": "Internal server error"}
- }
-)
-
-
-# ===== Helper Functions (instanceId-based routes, backend-driven like Trustee) =====
-
-def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
- """Parse pagination parameter from JSON string."""
- if not pagination:
- return None
- try:
- paginationDict = json.loads(pagination)
- if paginationDict:
- paginationDict = normalize_pagination_dict(paginationDict)
- return PaginationParams(**paginationDict)
- except (json.JSONDecodeError, ValueError) as e:
- raise HTTPException(
- status_code=400,
- detail=f"Invalid pagination parameter: {str(e)}"
- )
- return None
-
-
-async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
- """
- Validate that the user has access to the feature instance.
- Returns the mandateId for the instance.
- """
- rootInterface = getRootInterface()
- featureInterface = getFeatureInterface(rootInterface.db)
- instance = featureInterface.getFeatureInstance(instanceId)
- if not instance:
- raise HTTPException(
- status_code=404,
- detail=f"Feature instance '{instanceId}' not found"
- )
- if instance.featureCode != "realestate":
- raise HTTPException(
- status_code=400,
- detail=f"Instance '{instanceId}' is not a realestate instance"
- )
- if not context.isPlatformAdmin:
- featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
- hasAccess = any(
- str(fa.featureInstanceId) == instanceId and fa.enabled
- for fa in featureAccesses
- )
- if not hasAccess:
- raise HTTPException(
- status_code=403,
- detail=f"Access denied to feature instance '{instanceId}'"
- )
- return str(instance.mandateId)
-
-
-# Mapping of entity names to Pydantic model classes (for attributes endpoint)
-_REALESTATE_ENTITY_MODELS = {
- "Projekt": Projekt,
- "Parzelle": Parzelle,
- "Dokument": Dokument,
- "Gemeinde": Gemeinde,
- "Kanton": Kanton,
- "Land": Land,
-}
-
-
-# ============================================================================
-# INSTANCE-ID ROUTES (backend-driven, analog to Trustee)
-# ============================================================================
-
-@router.get("/{instanceId}/attributes/{entityType}", response_model=Dict[str, Any])
-@limiter.limit("30/minute")
-async def get_entity_attributes(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- entityType: str = Path(..., description="Entity type (e.g., Projekt, Parzelle)"),
- context: RequestContext = Depends(getRequestContext)
-) -> Dict[str, Any]:
- """Get attribute definitions for a Real Estate entity. Used by FormGeneratorTable."""
- await _validateInstanceAccess(instanceId, context)
- if entityType not in _REALESTATE_ENTITY_MODELS:
- raise HTTPException(
- status_code=404,
- detail=f"Unknown entity type: {entityType}. Valid types: {list(_REALESTATE_ENTITY_MODELS.keys())}"
- )
- modelClass = _REALESTATE_ENTITY_MODELS[entityType]
- try:
- attrDefs = getModelAttributeDefinitions(modelClass)
- visibleAttrs = [
- attr for attr in attrDefs.get("attributes", [])
- if isinstance(attr, dict) and attr.get("visible", True)
- ]
- return {"attributes": visibleAttrs}
- except Exception as e:
- logger.error(f"Error getting attributes for {entityType}: {e}")
- raise HTTPException(
- status_code=500,
- detail=f"Error getting attributes for {entityType}: {str(e)}"
- )
-
-
-@router.get("/{instanceId}/projects/options", response_model=List[Dict[str, Any]])
-@limiter.limit("60/minute")
-async def get_project_options(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- context: RequestContext = Depends(getRequestContext)
-) -> List[Dict[str, Any]]:
- """Get project options for select dropdowns. Returns: [{ value, label }]"""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- items = interface.getProjekte(recordFilter={"featureInstanceId": instanceId})
- return [{"value": p.id, "label": getattr(p, "label", None) or p.id} for p in items]
-
-
-@router.get("/{instanceId}/parcels/options", response_model=List[Dict[str, Any]])
-@limiter.limit("60/minute")
-async def get_parcel_options(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- context: RequestContext = Depends(getRequestContext)
-) -> List[Dict[str, Any]]:
- """Get parcel options for select dropdowns. Returns: [{ value, label }]"""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- items = interface.getParzellen(recordFilter={"featureInstanceId": instanceId})
- return [{"value": p.id, "label": getattr(p, "label", None) or p.id} for p in items]
-
-
-# ----- Projects CRUD -----
-
-@router.get("/{instanceId}/projects", response_model=PaginatedResponse[Projekt])
-@limiter.limit("30/minute")
-async def get_projects(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
- context: RequestContext = Depends(getRequestContext)
-) -> PaginatedResponse[Projekt]:
- """Get all projects for a feature instance with optional pagination."""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- recordFilter = {"featureInstanceId": instanceId}
- paginationParams = _parsePagination(pagination)
- if paginationParams:
- result = interface.getProjekte(pagination=paginationParams, recordFilter=recordFilter)
- if hasattr(result, 'items'):
- return PaginatedResponse(
- items=result.items,
- pagination=PaginationMetadata(
- currentPage=paginationParams.page,
- pageSize=paginationParams.pageSize,
- totalItems=result.totalItems,
- totalPages=result.totalPages,
- sort=paginationParams.sort or [],
- filters=paginationParams.filters
- )
- )
- items = interface.getProjekte(recordFilter=recordFilter)
- return PaginatedResponse(items=items, pagination=None)
-
-
-@router.get("/{instanceId}/projects/{projectId}", response_model=Projekt)
-@limiter.limit("30/minute")
-async def get_project_by_id(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- projectId: str = Path(..., description="Project ID"),
- context: RequestContext = Depends(getRequestContext)
-) -> Projekt:
- """Get a single project by ID."""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- projekt = interface.getProjekt(projectId)
- if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
- raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
- return projekt
-
-
-@router.post("/{instanceId}/projects", response_model=Projekt)
-@limiter.limit("30/minute")
-async def create_project(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- data: Dict[str, Any] = Body(...),
- context: RequestContext = Depends(getRequestContext)
-) -> Projekt:
- """Create a new project."""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- if "mandateId" not in data:
- data["mandateId"] = mandateId
- if "featureInstanceId" not in data:
- data["featureInstanceId"] = instanceId
- try:
- projekt = Projekt(**data)
- except Exception as e:
- raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}")
- return interface.createProjekt(projekt)
-
-
-@router.put("/{instanceId}/projects/{projectId}", response_model=Projekt)
-@limiter.limit("30/minute")
-async def update_project(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- projectId: str = Path(..., description="Project ID"),
- data: Dict[str, Any] = Body(...),
- context: RequestContext = Depends(getRequestContext)
-) -> Projekt:
- """Update a project."""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- projekt = interface.getProjekt(projectId)
- if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
- raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
- updated = interface.updateProjekt(projectId, data)
- if not updated:
- raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
- return updated
-
-
-@router.delete("/{instanceId}/projects/{projectId}", status_code=status.HTTP_204_NO_CONTENT)
-@limiter.limit("30/minute")
-async def delete_project(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- projectId: str = Path(..., description="Project ID"),
- context: RequestContext = Depends(getRequestContext)
-) -> None:
- """Delete a project."""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- projekt = interface.getProjekt(projectId)
- if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
- raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
- if not interface.deleteProjekt(projectId):
- raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
-
-
-# ----- Parcels CRUD -----
-
-@router.get("/{instanceId}/parcels", response_model=PaginatedResponse[Parzelle])
-@limiter.limit("30/minute")
-async def get_parcels(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
- context: RequestContext = Depends(getRequestContext)
-) -> PaginatedResponse[Parzelle]:
- """Get all parcels for a feature instance with optional pagination."""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- recordFilter = {"featureInstanceId": instanceId}
- paginationParams = _parsePagination(pagination)
- if paginationParams:
- result = interface.getParzellen(pagination=paginationParams, recordFilter=recordFilter)
- if hasattr(result, 'items'):
- return PaginatedResponse(
- items=result.items,
- pagination=PaginationMetadata(
- currentPage=paginationParams.page,
- pageSize=paginationParams.pageSize,
- totalItems=result.totalItems,
- totalPages=result.totalPages,
- sort=paginationParams.sort or [],
- filters=paginationParams.filters
- )
- )
- items = interface.getParzellen(recordFilter=recordFilter)
- return PaginatedResponse(items=items, pagination=None)
-
-
-@router.get("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
-@limiter.limit("30/minute")
-async def get_parcel_by_id(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- parcelId: str = Path(..., description="Parcel ID"),
- context: RequestContext = Depends(getRequestContext)
-) -> Parzelle:
- """Get a single parcel by ID."""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- parzelle = interface.getParzelle(parcelId)
- if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
- raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
- return parzelle
-
-
-@router.post("/{instanceId}/parcels", response_model=Parzelle)
-@limiter.limit("30/minute")
-async def create_parcel(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- data: Dict[str, Any] = Body(...),
- context: RequestContext = Depends(getRequestContext)
-) -> Parzelle:
- """Create a new parcel."""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- if "mandateId" not in data:
- data["mandateId"] = mandateId
- if "featureInstanceId" not in data:
- data["featureInstanceId"] = instanceId
- try:
- parzelle = Parzelle(**data)
- except Exception as e:
- raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}")
- return interface.createParzelle(parzelle)
-
-
-@router.put("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
-@limiter.limit("30/minute")
-async def update_parcel(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- parcelId: str = Path(..., description="Parcel ID"),
- data: Dict[str, Any] = Body(...),
- context: RequestContext = Depends(getRequestContext)
-) -> Parzelle:
- """Update a parcel."""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- parzelle = interface.getParzelle(parcelId)
- if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
- raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
- updated = interface.updateParzelle(parcelId, data)
- if not updated:
- raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
- return updated
-
-
-@router.delete("/{instanceId}/parcels/{parcelId}", status_code=status.HTTP_204_NO_CONTENT)
-@limiter.limit("30/minute")
-async def delete_parcel(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- parcelId: str = Path(..., description="Parcel ID"),
- context: RequestContext = Depends(getRequestContext)
-) -> None:
- """Delete a parcel."""
- mandateId = await _validateInstanceAccess(instanceId, context)
- interface = getRealEstateInterface(
- context.user, mandateId=mandateId, featureInstanceId=instanceId
- )
- parzelle = interface.getParzelle(parcelId)
- if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
- raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
- if not interface.deleteParzelle(parcelId):
- raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
-
-
-# ============================================================================
-# LEGACY / STATELESS ROUTES (unchanged)
-# ============================================================================
-
-@router.post("/command", response_model=Dict[str, Any])
-@limiter.limit("120/minute")
-async def process_command(
- request: Request,
- userInput: str = Body(..., embed=True, description="Natural language command"),
- currentUser: User = Depends(getCurrentUser)
-) -> Dict[str, Any]:
- """
- Process natural language command and execute corresponding CRUD operation.
-
- Uses AI to analyze user intent and extract parameters, then executes the appropriate
- CRUD operation. Works stateless without session management.
-
- Example user inputs:
- - "Erstelle ein neues Projekt namens 'Hauptstrasse 42'"
- - "Zeige mir alle Projekte in Zürich"
- - "Aktualisiere Projekt XYZ mit Status 'Planung'"
- - "Lösche Parzelle ABC"
- - "SELECT * FROM Projekt WHERE plz = '8000'"
-
- Headers:
- - X-CSRF-Token: CSRF token (required for security)
-
- Returns:
- {
- "success": true,
- "intent": "CREATE|READ|UPDATE|DELETE|QUERY",
- "entity": "Projekt|Parzelle|...|null",
- "result": {...}
- }
- """
- try:
- # Validate CSRF token (middleware also checks, but explicit validation for better error messages)
- csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
- if not csrf_token:
- logger.warning(f"CSRF token missing for POST /api/realestate/command from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
- )
-
- # Basic CSRF token format validation
- if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
- logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- # Validate token is hex string
- try:
- int(csrf_token, 16)
- except ValueError:
- logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- logger.info(f"Processing command request from user {currentUser.id} (mandate: {currentUser.mandateId})")
- logger.debug(f"User input: {userInput}")
-
- # Process natural language command with AI
- result = await processNaturalLanguageCommand(
- currentUser=currentUser,
- userInput=userInput
- )
-
- return result
-
- except ValueError as e:
- logger.error(f"Validation error in process_command: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Validation error: {str(e)}"
- )
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error processing command: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error processing command: {str(e)}"
- )
-
-@router.get("/tables", response_model=Dict[str, Any])
-@limiter.limit("120/minute")
-async def get_available_tables(
- request: Request,
- currentUser: User = Depends(getCurrentUser)
-) -> Dict[str, Any]:
- """
- Get all available real estate tables.
-
- Returns a list of available table names with their descriptions.
-
- Headers:
- - X-CSRF-Token: CSRF token (required for security)
-
- Example:
- - GET /api/realestate/tables
- """
- try:
- # Validate CSRF token if provided
- csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
- if not csrf_token:
- logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
- )
-
- # Basic CSRF token format validation
- if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
- logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- # Validate token is hex string
- try:
- int(csrf_token, 16)
- except ValueError:
- logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- logger.info(f"Getting available tables for user {currentUser.id} (mandate: {currentUser.mandateId})")
-
- # Define available tables with descriptions
- tables = [
- {
- "name": "Projekt",
- "description": "Real estate projects",
- "model": "Projekt"
- },
- {
- "name": "Parzelle",
- "description": "Plots/parcels",
- "model": "Parzelle"
- },
- {
- "name": "Dokument",
- "description": "Documents",
- "model": "Dokument"
- },
- {
- "name": "Gemeinde",
- "description": "Municipalities",
- "model": "Gemeinde"
- },
- {
- "name": "Kanton",
- "description": "Cantons",
- "model": "Kanton"
- },
- {
- "name": "Land",
- "description": "Countries",
- "model": "Land"
- },
- ]
-
- return {
- "tables": tables,
- "count": len(tables)
- }
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error getting available tables: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error getting available tables: {str(e)}"
- )
-
-
-@router.get("/table/{table}", response_model=PaginatedResponse[Any])
-@limiter.limit("120/minute")
-async def get_table_data(
- request: Request,
- table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
- pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
- currentUser: User = Depends(getCurrentUser)
-) -> PaginatedResponse[Dict[str, Any]]:
- """
- Get all data from a specific real estate table with optional pagination.
-
- Available tables:
- - Projekt: Real estate projects
- - Parzelle: Plots/parcels
- - Dokument: Documents
- - Gemeinde: Municipalities
- - Kanton: Cantons
- - Land: Countries
-
- Query Parameters:
- - pagination: JSON-encoded PaginationParams object, or None for no pagination
-
- Headers:
- - X-CSRF-Token: CSRF token (required for security)
-
- Examples:
- - GET /api/realestate/table/Projekt (no pagination - returns all items)
- - GET /api/realestate/table/Parzelle?pagination={"page":1,"pageSize":10,"sort":[]}
- - GET /api/realestate/table/Gemeinde?pagination={"page":2,"pageSize":20,"sort":[{"field":"label","direction":"asc"}]}
- """
- try:
- # Validate CSRF token if provided
- csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
- if not csrf_token:
- logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
- )
-
- # Basic CSRF token format validation
- if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
- logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- # Validate token is hex string
- try:
- int(csrf_token, 16)
- except ValueError:
- logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- logger.info(f"Getting table data for '{table}' from user {currentUser.id} (mandate: {currentUser.mandateId})")
-
- # Map table names to model classes and getter methods
- table_mapping = {
- "Projekt": (Projekt, "getProjekte"),
- "Parzelle": (Parzelle, "getParzellen"),
- "Dokument": (Dokument, "getDokumente"),
- "Gemeinde": (Gemeinde, "getGemeinden"),
- "Kanton": (Kanton, "getKantone"),
- "Land": (Land, "getLaender"),
- }
-
- # Validate table name
- if table not in table_mapping:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Invalid table name '{table}'. Available tables: {', '.join(table_mapping.keys())}"
- )
-
- # Get interface and fetch data
- realEstateInterface = getRealEstateInterface(currentUser)
- model_class, method_name = table_mapping[table]
- getter_method = getattr(realEstateInterface, method_name)
-
- # Fetch all records (no filter for now)
- records = getter_method(recordFilter=None)
-
- # Keep records as model instances (like routeDataFiles does with FileItem)
- # FastAPI will automatically serialize Pydantic models to JSON
- items = records
-
- # Parse pagination parameter
- paginationParams = None
- if pagination:
- try:
- paginationDict = json.loads(pagination)
- paginationParams = PaginationParams(**paginationDict) if paginationDict else None
- except (json.JSONDecodeError, ValueError) as e:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Invalid pagination parameter: {str(e)}"
- )
-
- # Apply pagination if requested
- if paginationParams:
- # Apply sorting if specified
- if paginationParams.sort:
- for sort_field in reversed(paginationParams.sort): # Reverse to apply in priority order
- field_name = sort_field.field
- direction = sort_field.direction.lower()
-
- def sort_key(item):
- # Access attribute from model instance
- value = getattr(item, field_name, None)
- # Handle None values - put them at the end for asc, at the start for desc
- if value is None:
- return (1, None) # Use tuple to ensure None values sort consistently
- return (0, value)
-
- items.sort(key=sort_key, reverse=(direction == "desc"))
-
- # Apply pagination
- total_items = len(items)
- total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize # Ceiling division
- start_idx = (paginationParams.page - 1) * paginationParams.pageSize
- end_idx = start_idx + paginationParams.pageSize
- paginated_items = items[start_idx:end_idx]
-
- return PaginatedResponse(
- items=paginated_items,
- pagination=PaginationMetadata(
- currentPage=paginationParams.page,
- pageSize=paginationParams.pageSize,
- totalItems=total_items,
- totalPages=total_pages,
- sort=paginationParams.sort,
- filters=paginationParams.filters
- )
- )
- else:
- # No pagination - return all items (as model instances, like routeDataFiles)
- return PaginatedResponse(
- items=items,
- pagination=None
- )
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error getting table data for '{table}': {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error getting table data: {str(e)}"
- )
-
-
-@router.post("/table/{table}", response_model=Dict[str, Any])
-@limiter.limit("120/minute")
-async def create_table_record(
- request: Request,
- table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
- data: Dict[str, Any] = Body(..., description="Record data to create"),
- currentUser: User = Depends(getCurrentUser)
-) -> Dict[str, Any]:
- """
- Create a new record in a specific real estate table.
-
- Available tables:
- - Projekt: Real estate projects (with parcel data support)
- - Parzelle: Plots/parcels
- - Dokument: Documents
- - Gemeinde: Municipalities
- - Kanton: Cantons
- - Land: Countries
-
- Request Body:
- For Projekt:
- {
- "label": "Projekt Bezeichnung",
- "statusProzess": "Eingang", // Optional
- "parzelle": {
- "id": "OE5913",
- "egrid": "CH252699779137",
- "perimeter": {...},
- "geometry": {...}, // Used for baulinie
- ...
- }
- }
-
- For other tables:
- - JSON object with fields matching the table's data model
-
- Headers:
- - X-CSRF-Token: CSRF token (required for security)
-
- Examples:
- - POST /api/realestate/table/Projekt
- Body: {"label": "Hauptstrasse 42", "parzelle": {...}}
- - POST /api/realestate/table/Parzelle
- Body: {"label": "Parzelle 1", "strasseNr": "Hauptstrasse 42", "plz": "8000", "bauzone": "W3"}
- """
- try:
- # Validate CSRF token
- csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
- if not csrf_token:
- logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
- )
-
- # Basic CSRF token format validation
- if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
- logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- # Validate token is hex string
- try:
- int(csrf_token, 16)
- except ValueError:
- logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- # Special handling for Projekt with parcel data
- if table == "Projekt" and ("parzelle" in data or "parzellen" in data):
- logger.info(f"Creating Projekt with parcel data for user {currentUser.id} (mandate: {currentUser.mandateId})")
-
- # Extract fields
- label = data.get("label")
- if not label:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=routeApiMsg("label is required")
- )
-
- status_prozess = data.get("statusProzess", "Eingang")
-
- # Support both single parzelle and multiple parzellen
- parzellen_data = []
- if "parzellen" in data:
- # Multiple parcels
- parzellen_data = data.get("parzellen", [])
- if not isinstance(parzellen_data, list):
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=routeApiMsg("parzellen must be an array")
- )
- elif "parzelle" in data:
- # Single parcel (backward compatibility)
- parzelle_data = data.get("parzelle")
- if parzelle_data:
- parzellen_data = [parzelle_data]
-
- if not parzellen_data:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=routeApiMsg("parzelle or parzellen data is required")
- )
-
- # Use helper function to create project with parcel data
- try:
- result = await create_project_with_parcel_data(
- currentUser=currentUser,
- projekt_label=label,
- parzellen_data=parzellen_data,
- status_prozess=status_prozess,
- )
-
- # Return in format expected by frontend (single record, not nested)
- return result.get("projekt", {})
- except HTTPException:
- # Re-raise HTTPExceptions directly
- raise
- except Exception as e:
- logger.error(f"Error creating Projekt with parcel data: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error creating Projekt: {str(e)}"
- )
-
- # Standard handling for other tables or Projekt without parcel data
- logger.info(f"Creating record in table '{table}' for user {currentUser.id} (mandate: {currentUser.mandateId})")
- logger.debug(f"Record data: {data}")
-
- # Map table names to model classes and create methods
- table_mapping = {
- "Projekt": (Projekt, "createProjekt"),
- "Parzelle": (Parzelle, "createParzelle"),
- "Dokument": (Dokument, "createDokument"),
- "Gemeinde": (Gemeinde, "createGemeinde"),
- "Kanton": (Kanton, "createKanton"),
- "Land": (Land, "createLand"),
- }
-
- # Validate table name
- if table not in table_mapping:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Invalid table name '{table}'. Available tables: {', '.join(table_mapping.keys())}"
- )
-
- # Get interface
- realEstateInterface = getRealEstateInterface(currentUser)
- model_class, method_name = table_mapping[table]
- create_method = getattr(realEstateInterface, method_name)
-
- # Ensure mandateId is set (will be set by interface if missing)
- if "mandateId" not in data:
- data["mandateId"] = currentUser.mandateId
-
- # Create model instance from data
- try:
- model_instance = model_class(**data)
- except Exception as e:
- logger.error(f"Error creating {table} model instance: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Invalid data for {table}: {str(e)}"
- )
-
- # Create record
- try:
- created_record = create_method(model_instance)
-
- # Convert to dictionary for response
- if hasattr(created_record, 'model_dump'):
- return created_record.model_dump()
- else:
- return created_record
-
- except Exception as e:
- logger.error(f"Error creating {table} record: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error creating {table} record: {str(e)}"
- )
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error creating record in table '{table}': {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error creating record: {str(e)}"
- )
-
-
-@router.get("/parcel/search", response_model=Dict[str, Any])
-@limiter.limit("60/minute")
-async def search_parcel(
- request: Request,
- location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"),
- include_adjacent: bool = Query(False, description="Include adjacent parcels information"),
- fetch_documents: bool = Query(True, description="If true, fetch BZO documents for the Gemeinde (default: true)"),
- currentUser: User = Depends(getCurrentUser)
-) -> Dict[str, Any]:
- """
- Search for parcel information by address or coordinates.
-
- Returns comprehensive parcel information including:
- - Parcel identification (number, EGRID, etc.)
- - Precise boundary geometry for map display
- - Administrative context (canton, municipality)
- - Link to official cadastral map
- - Optional: Adjacent parcels
- - Optional: Gemeinde information and BZO documents (if fetch_documents=true)
-
- Query Parameters:
- - location: Either coordinates as "x,y" (LV95/EPSG:2056) or address string
- - include_adjacent: If true, fetches information about adjacent parcels (slower)
- - fetch_documents: If true, checks for and fetches Bauzonenverordnung (BZO) documents for the Gemeinde (default: true, slower)
-
- Headers:
- - X-CSRF-Token: CSRF token (required for security)
-
- Examples:
- - GET /api/realestate/parcel/search?location=2600000,1200000
- - GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern
- - GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern&include_adjacent=true
- - GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern&fetch_documents=true
-
- Returns:
- {
- "parcel": {
- "id": "823",
- "egrid": "CH294676423526",
- "number": "823",
- "name": "823",
- "identnd": "BE0200000042",
- "canton": "BE",
- "municipality_code": 351,
- "municipality_name": "Bern",
- "address": "Bundesplatz 3 3011 Bern",
- "plz": "3011",
- "perimeter": {...},
- "area_m2": 1234.56,
- "centroid": {"x": 2600000, "y": 1200000},
- "geoportal_url": "https://...",
- "realestate_type": null,
- "bauzone": "W3"
- },
- "map_view": {
- "center": {"x": 2600000, "y": 1200000},
- "zoom_bounds": {"min_x": ..., "max_x": ..., "min_y": ..., "max_y": ...},
- "geometry_geojson": {...}
- },
- "adjacent_parcels": [...], // Optional (only if include_adjacent=true)
- "gemeinde": { // Optional (only if fetch_documents=true)
- "id": "...",
- "label": "Bern",
- "plz": "3011"
- },
- "documents": [ // Optional (only if fetch_documents=true and documents found/created)
- {
- "id": "...",
- "label": "BZO Bern",
- "dokumentTyp": "gemeindeBzoAktuell",
- "dokumentReferenz": "...",
- "quelle": "https://...",
- "mimeType": "application/pdf"
- }
- ]
- }
- """
- try:
- # Validate CSRF token
- csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
- if not csrf_token:
- logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
- )
-
- logger.info(f"Searching parcel for user {currentUser.id} (mandate: {currentUser.mandateId}) with location: {location}")
-
- # Initialize connector
- connector = SwissTopoMapServerConnector()
-
- # Search for parcel
- parcel_data = await connector.search_parcel(location)
-
- if not parcel_data:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"No parcel found for location: {location}"
- )
-
- # Extract and normalize attributes
- extracted_attributes = connector.extract_parcel_attributes(parcel_data)
- attributes = parcel_data.get("attributes", {})
- geometry = parcel_data.get("geometry", {})
-
- # Calculate parcel area from perimeter
- area_m2 = None
- centroid = None
- if extracted_attributes.get("perimeter"):
- perimeter = extracted_attributes["perimeter"]
- points = perimeter.get("punkte", [])
-
- # Calculate area using shoelace formula
- if len(points) >= 3:
- area = 0
- for i in range(len(points)):
- j = (i + 1) % len(points)
- area += points[i]["x"] * points[j]["y"]
- area -= points[j]["x"] * points[i]["y"]
- area_m2 = abs(area / 2)
-
- # Calculate centroid
- sum_x = sum(p["x"] for p in points)
- sum_y = sum(p["y"] for p in points)
- centroid = {
- "x": sum_x / len(points),
- "y": sum_y / len(points)
- }
-
- # Extract municipality name and address from Swiss Topo data
- municipality_name = None
- full_address = None
- plz = None
- canton = attributes.get("ak") # Extract canton early so it's always available
-
- # Debug: Log all available attributes to understand what we have
- logger.debug(f"Parcel attributes keys: {list(attributes.keys())}")
- logger.debug(f"Sample parcel attributes: {dict(list(attributes.items())[:10])}") # First 10 items
-
- # First, check if municipality is directly in parcel attributes (ggdename or dplzname)
- # These fields are often present in the parcel data itself from Swiss Topo
- municipality_from_attrs = attributes.get("ggdename") or attributes.get("dplzname") or attributes.get("gemeinde") or attributes.get("gemeindename")
- if municipality_from_attrs:
- # Use connector's cleaning method to remove canton suffix
- municipality_name = connector._clean_municipality_name(str(municipality_from_attrs))
- logger.info(f"Found municipality '{municipality_name}' in parcel attributes (from {municipality_from_attrs})")
-
- # Also check extracted_attributes for municipality
- if not municipality_name:
- municipality_from_extracted = extracted_attributes.get("kontextGemeinde")
- if municipality_from_extracted:
- municipality_name = str(municipality_from_extracted)
- logger.info(f"Found municipality '{municipality_name}' in extracted attributes")
-
- # Also check for PLZ in parcel attributes
- if not plz:
- plz_from_attrs = attributes.get("dplz4") or attributes.get("plz")
- if plz_from_attrs:
- plz = str(plz_from_attrs).strip()
- logger.debug(f"Found PLZ '{plz}' in parcel attributes")
-
- # Try to use geocoded address info if available (more accurate than centroid query)
- geocoded_address = parcel_data.get('geocoded_address')
- if geocoded_address:
- if not full_address:
- full_address = geocoded_address.get('full_address')
- if not plz:
- plz = geocoded_address.get('plz')
- if not municipality_name:
- geocoded_municipality = geocoded_address.get('municipality')
- if geocoded_municipality:
- municipality_name = connector._clean_municipality_name(geocoded_municipality)
- logger.debug(f"Found municipality '{municipality_name}' from geocoded address")
- if full_address:
- logger.debug(f"Using geocoded address: {full_address}")
-
- # If geocoded address not available, try to get address by querying the address layer
- # Use query coordinates (where user clicked/geocoded) instead of parcel centroid
- # This ensures we get the address at the exact location, not at the parcel center
- query_coords = parcel_data.get('query_coordinates')
- address_query_coords = query_coords if query_coords else centroid
-
- if not full_address and address_query_coords:
- query_x = address_query_coords['x']
- query_y = address_query_coords['y']
- logger.debug(f"Querying address layer at query coordinates: ({query_x}, {query_y})")
-
- # Check if this was a coordinate search (not geocoded address)
- is_coordinate_search = ',' in location and not any(c.isalpha() for c in location.split(',')[0])
-
- # Use connector's helper method to query building layer
- # Use tolerance=1 (minimum) for coordinate searches to get exact building
- building_tolerance = 1 if is_coordinate_search else 10
- building_result = await connector._query_building_layer(query_x, query_y, tolerance=building_tolerance, buffer=25)
-
- if building_result:
- addr_attrs = building_result.get("attributes", {})
- logger.debug(f"Address layer attributes: {addr_attrs}")
-
- # Extract address using connector's helper method
- address_info = connector._extract_address_from_building_attrs(addr_attrs)
- if not full_address:
- full_address = address_info.get('full_address')
- if not plz:
- plz = address_info.get('plz')
- if not municipality_name:
- municipality_name = address_info.get('municipality')
- if municipality_name:
- logger.debug(f"Found municipality '{municipality_name}' from building layer")
-
- if full_address:
- logger.debug(f"Constructed address: {full_address}")
-
- # If address not found via building layer, try to construct from available data
- if not full_address:
- # Check if location was provided as an address string
- if location and any(c.isalpha() for c in location) and "CH" not in location:
- # Location looks like an address (not an EGRID)
- full_address = location
- logger.debug(f"Using location as address: {full_address}")
-
- # Try to extract municipality name from address string if not found yet
- if not municipality_name and full_address:
- # Parse address string to extract municipality name
- # Format is usually: "Street Number, PLZ Municipality" or "Street Number PLZ Municipality"
- # Examples: "Forchstrasse 6c, 8610 Uster" or "Bundesplatz 3 3011 Bern"
- # Try to match PLZ followed by municipality name
- # PLZ is typically 4 digits, municipality name follows
- plz_municipality_match = re.search(r'\b(\d{4})\s+([A-ZÄÖÜ][a-zäöüß\s-]+)', full_address)
- if plz_municipality_match:
- extracted_plz = plz_municipality_match.group(1)
- extracted_municipality = plz_municipality_match.group(2).strip()
- # Remove trailing commas or other punctuation
- extracted_municipality = re.sub(r'[,;\.]+$', '', extracted_municipality).strip()
- if extracted_municipality:
- municipality_name = extracted_municipality
- if not plz:
- plz = extracted_plz
- logger.debug(f"Extracted municipality '{municipality_name}' and PLZ '{plz}' from address string")
-
- # Try to extract municipality name from BFSNR if still not found
- if not municipality_name:
- bfsnr = attributes.get("bfsnr")
-
- logger.info(f"Attempting to resolve municipality name for BFS number {bfsnr} in canton {canton}")
-
- # Try to query database for Gemeinde by BFS number
- if bfsnr and canton:
- try:
- realEstateInterface = getRealEstateInterface(currentUser)
- # Query Gemeinde by BFS number (stored in kontextInformationen)
- gemeinden = realEstateInterface.getGemeinden(
- recordFilter={"mandateId": currentUser.mandateId}
- )
- logger.debug(f"Found {len(gemeinden)} Gemeinden in database, searching for BFS {bfsnr}")
- for gemeinde in gemeinden:
- # Check kontextInformationen for BFS number
- for kontext in gemeinde.kontextInformationen:
- try:
- kontext_data = json.loads(kontext.inhalt) if isinstance(kontext.inhalt, str) else kontext.inhalt
- if isinstance(kontext_data, dict):
- kontext_bfsnr = kontext_data.get("bfs_nummer") or kontext_data.get("bfsnr") or kontext_data.get("municipality_code")
- if str(kontext_bfsnr) == str(bfsnr):
- municipality_name = gemeinde.label
- logger.info(f"Found Gemeinde '{municipality_name}' by BFS number {bfsnr} in database")
- break
- except (json.JSONDecodeError, AttributeError) as e:
- logger.debug(f"Error parsing kontext: {e}")
- continue
- if municipality_name:
- break
- except Exception as e:
- logger.warning(f"Error querying Gemeinde by BFS number: {e}", exc_info=True)
-
- # If still not found, try to use Swiss Topo geocoding API to get municipality name from coordinates
- # This is more reliable than BFS number lookup since coordinates are exact
- if not municipality_name and centroid:
- try:
- # Use Swiss Topo geocoding to get municipality name from coordinates
- geocode_url = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify"
- params = {
- "geometry": f"{centroid['x']},{centroid['y']}",
- "geometryType": "esriGeometryPoint",
- "layers": "all:ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill",
- "tolerance": "0",
- "returnGeometry": "false",
- "sr": "2056"
- }
- import aiohttp
- ssl_context = ssl.create_default_context()
- ssl_context.check_hostname = False
- ssl_context.verify_mode = ssl.CERT_NONE
- connector_aiohttp = aiohttp.TCPConnector(ssl=ssl_context)
- async with aiohttp.ClientSession(connector=connector_aiohttp) as session:
- async with session.get(geocode_url, params=params) as resp:
- if resp.status == 200:
- data = await resp.json()
- results = data.get("results", [])
- if results:
- result_attrs = results[0].get("attributes", {})
- geocoded_municipality = result_attrs.get("name") or result_attrs.get("gemeindename") or result_attrs.get("label")
- if geocoded_municipality:
- municipality_name = connector._clean_municipality_name(str(geocoded_municipality))
- logger.info(f"Found municipality '{municipality_name}' via Swiss Topo geocoding API (from {geocoded_municipality})")
- except Exception as e:
- logger.debug(f"Error querying Swiss Topo geocoding API: {e}", exc_info=True)
-
- # If still not found, try expanded Swiss municipalities lookup
- if not municipality_name and bfsnr:
- # Expanded Swiss municipalities lookup by BFS number
- # Source: https://www.bfs.admin.ch/bfs/de/home/grundlagen/agvch.html
- common_municipalities = {
- # Zürich (ZH)
- 261: "Zürich",
- 198: "Pfäffikon", # ZH-198 is Pfäffikon
- 191: "Uster", # Uster is ZH-191
- 3203: "Winterthur",
- # Bern (BE)
- 351: "Bern",
- # Basel (BS)
- 2701: "Basel",
- # Genève (GE)
- 6621: "Genève",
- # Vaud (VD)
- 5586: "Lausanne",
- # Luzern (LU)
- 1061: "Luzern",
- # St. Gallen (SG)
- 230: "St. Gallen",
- # Ticino (TI)
- 5192: "Lugano",
- # Schwyz (SZ)
- 1367: "Schwyz",
- }
-
- if bfsnr in common_municipalities:
- municipality_name = common_municipalities[bfsnr]
- logger.info(f"Looked up municipality '{municipality_name}' from common list for BFS {bfsnr}")
-
- # If still not found, log warning
- if not municipality_name:
- logger.warning(f"Could not determine municipality name for BFS number {bfsnr} in canton {canton}. Municipality name will be None.")
-
- # Final validation: Don't use EGRID as address
- if full_address and full_address.startswith("CH") and len(full_address) == 14 and full_address[2:].isdigit():
- # This is an EGRID, not an address
- full_address = None
- logger.debug("Removed EGRID from address field")
-
- # Query zone information (wohnzone/bauzone) from ÖREB WFS
- bauzone = None
- # Check if geometry has actual data (either rings or coordinates)
- has_geometry = geometry and (geometry.get("rings") or geometry.get("coordinates"))
- if canton and has_geometry:
- try:
- logger.debug(f"Querying zone information for parcel {attributes.get('label')} in canton {canton}")
- oereb_connector = OerebWfsConnector()
- egrid = attributes.get("egris_egrid", "")
- x = centroid["x"] if centroid else None
- y = centroid["y"] if centroid else None
-
- # Query zone layer using parcel geometry
- zone_results = await oereb_connector.query_zone_layer(
- egrid=egrid,
- x=x or 0.0,
- y=y or 0.0,
- canton=canton,
- geometry=geometry
- )
-
- if zone_results and len(zone_results) > 0:
- zone_attrs = zone_results[0].get("attributes", {})
- typ_gde_abkuerzung = zone_attrs.get("typ_gde_abkuerzung")
- if typ_gde_abkuerzung:
- bauzone = typ_gde_abkuerzung
- logger.info(f"Found bauzone/wohnzone: {bauzone} for parcel {attributes.get('label')}")
- else:
- logger.debug(f"No typ_gde_abkuerzung found in zone results for parcel {attributes.get('label')}")
- else:
- logger.debug(f"No zone results found for parcel {attributes.get('label')}")
- except Exception as e:
- logger.warning(f"Error querying zone information: {e}", exc_info=True)
- # Continue without zone information if query fails
-
- # Build parcel info
- parcel_info = {
- "id": attributes.get("label") or attributes.get("number"),
- "egrid": attributes.get("egris_egrid"),
- "number": attributes.get("number"),
- "name": attributes.get("name"),
- "identnd": attributes.get("identnd"),
- "canton": attributes.get("ak"),
- "municipality_code": attributes.get("bfsnr"),
- "municipality_name": municipality_name,
- "address": full_address,
- "plz": plz,
- "perimeter": extracted_attributes.get("perimeter"),
- "area_m2": area_m2,
- "centroid": centroid,
- "geoportal_url": attributes.get("geoportal_url"),
- "realestate_type": attributes.get("realestate_type"),
- "bauzone": bauzone
- }
-
- # Build map view info
- bbox = parcel_data.get("bbox", [])
- map_view = {
- "center": centroid,
- "zoom_bounds": {
- "min_x": bbox[0] if len(bbox) >= 4 else None,
- "min_y": bbox[1] if len(bbox) >= 4 else None,
- "max_x": bbox[2] if len(bbox) >= 4 else None,
- "max_y": bbox[3] if len(bbox) >= 4 else None
- },
- "geometry_geojson": {
- "type": "Feature",
- "geometry": {
- "type": "Polygon",
- "coordinates": [
- [[p["x"], p["y"]] for p in extracted_attributes["perimeter"]["punkte"]]
- ] if extracted_attributes.get("perimeter") else []
- },
- "properties": {
- "id": parcel_info["id"],
- "egrid": parcel_info["egrid"],
- "number": parcel_info["number"]
- }
- }
- }
-
- # Build response
- response_data = {
- "parcel": parcel_info,
- "map_view": map_view
- }
-
- # Fetch adjacent parcels if requested
- if include_adjacent and parcel_data and parcel_data.get("geometry"):
- try:
- # Use the connector's method to find neighboring parcels by sampling along the boundary
- # This ensures we find all parcels that actually touch the selected parcel
- selected_parcel_id = parcel_info["id"]
- adjacent_parcels_raw = await connector.find_neighboring_parcels(
- parcel_data=parcel_data,
- selected_parcel_id=selected_parcel_id,
- sample_distance=20.0, # Sample every 20 meters (balanced for coverage and speed)
- max_sample_points=30, # Allow up to 30 points to ensure all vertices are covered
- max_neighbors=15, # Find up to 15 neighbors
- max_concurrent=50 # Process up to 50 queries concurrently (maximum parallelization)
- )
-
- # Convert adjacent parcels to include GeoJSON geometry (optimized, minimal logging)
- def convert_parcel_geometry(adj_parcel: Dict[str, Any]) -> Dict[str, Any]:
- """Convert a single adjacent parcel to include GeoJSON geometry."""
- adj_parcel_with_geo = {
- "id": adj_parcel["id"],
- "egrid": adj_parcel.get("egrid"),
- "number": adj_parcel.get("number"),
- "perimeter": adj_parcel.get("perimeter")
- }
-
- # Convert geometry to GeoJSON format if available
- adj_geometry = adj_parcel.get("geometry")
- adj_perimeter = adj_parcel.get("perimeter")
-
- if adj_geometry:
- # Handle ESRI format (rings)
- if "rings" in adj_geometry and adj_geometry["rings"]:
- ring = adj_geometry["rings"][0] # Outer ring
- coordinates = [[[p[0], p[1]] for p in ring]]
- adj_parcel_with_geo["geometry_geojson"] = {
- "type": "Feature",
- "geometry": {
- "type": "Polygon",
- "coordinates": coordinates
- },
- "properties": {
- "id": adj_parcel["id"],
- "egrid": adj_parcel.get("egrid"),
- "number": adj_parcel.get("number")
- }
- }
- # Handle GeoJSON format
- elif adj_geometry.get("type") == "Polygon":
- adj_parcel_with_geo["geometry_geojson"] = {
- "type": "Feature",
- "geometry": adj_geometry,
- "properties": {
- "id": adj_parcel["id"],
- "egrid": adj_parcel.get("egrid"),
- "number": adj_parcel.get("number")
- }
- }
-
- # If no geometry_geojson was created but we have perimeter, create it from perimeter
- if "geometry_geojson" not in adj_parcel_with_geo and adj_perimeter and adj_perimeter.get("punkte"):
- punkte = adj_perimeter["punkte"]
- coordinates = [[[p["x"], p["y"]] for p in punkte]]
- adj_parcel_with_geo["geometry_geojson"] = {
- "type": "Feature",
- "geometry": {
- "type": "Polygon",
- "coordinates": coordinates
- },
- "properties": {
- "id": adj_parcel["id"],
- "egrid": adj_parcel.get("egrid"),
- "number": adj_parcel.get("number")
- }
- }
-
- return adj_parcel_with_geo
-
- # Convert all parcels in parallel (using list comprehension for speed)
- adjacent_parcels = [convert_parcel_geometry(adj_parcel) for adj_parcel in adjacent_parcels_raw]
-
- response_data["adjacent_parcels"] = adjacent_parcels
- logger.info(f"Found {len(adjacent_parcels)} neighboring parcels for parcel {selected_parcel_id}")
-
- except Exception as e:
- logger.warning(f"Error fetching adjacent parcels: {e}", exc_info=True)
- response_data["adjacent_parcels"] = []
-
- # Fetch BZO documents if requested
- gemeinde_info = None
- bzo_documents = []
-
- logger.debug(f"Document fetch check: fetch_documents={fetch_documents}, municipality_name={municipality_name}, canton={canton}")
-
- if fetch_documents and municipality_name and canton:
- logger.info(f"Fetching BZO documents for Gemeinde '{municipality_name}' in canton '{canton}'")
- try:
- # Get interfaces
- realEstateInterface = getRealEstateInterface(currentUser)
- componentInterface = getComponentInterface(currentUser)
- logger.debug(f"Interfaces initialized for document fetching")
-
- # Resolve or create Gemeinde
- gemeinde = None
- # First, ensure Land "Schweiz" exists
- laender = realEstateInterface.getLaender(recordFilter={"label": "Schweiz"})
- if not laender:
- land = Land(
- mandateId=currentUser.mandateId,
- label="Schweiz",
- abk="CH"
- )
- land = realEstateInterface.createLand(land)
- logger.debug(f"Created Land 'Schweiz' with ID: {land.id}")
- else:
- land = laender[0]
-
- # Map canton abbreviations to full names
- canton_names = {
- "ZH": "Zürich", "BE": "Bern", "LU": "Luzern", "UR": "Uri", "SZ": "Schwyz",
- "OW": "Obwalden", "NW": "Nidwalden", "GL": "Glarus", "ZG": "Zug", "FR": "Freiburg",
- "SO": "Solothurn", "BS": "Basel-Stadt", "BL": "Basel-Landschaft", "SH": "Schaffhausen",
- "AR": "Appenzell Ausserrhoden", "AI": "Appenzell Innerrhoden", "SG": "St. Gallen",
- "GR": "Graubünden", "AG": "Aargau", "TG": "Thurgau", "TI": "Tessin",
- "VD": "Waadt", "VS": "Wallis", "NE": "Neuenburg", "GE": "Genf", "JU": "Jura"
- }
-
- # Get or create Kanton
- kantone = realEstateInterface.getKantone(recordFilter={"abk": canton})
- if not kantone:
- kanton_label = canton_names.get(canton, canton)
- kanton_obj = Kanton(
- mandateId=currentUser.mandateId,
- label=kanton_label,
- abk=canton,
- id_land=land.id
- )
- kanton_obj = realEstateInterface.createKanton(kanton_obj)
- logger.debug(f"Created Kanton '{kanton_label}' ({canton})")
- else:
- kanton_obj = kantone[0]
-
- # Get or create Gemeinde
- gemeinden = realEstateInterface.getGemeinden(
- recordFilter={"label": municipality_name, "id_kanton": kanton_obj.id}
- )
- if not gemeinden:
- gemeinde = Gemeinde(
- mandateId=currentUser.mandateId,
- label=municipality_name,
- id_kanton=kanton_obj.id,
- plz=plz
- )
- gemeinde = realEstateInterface.createGemeinde(gemeinde)
- logger.info(f"Created Gemeinde '{municipality_name}'")
- else:
- gemeinde = gemeinden[0]
- logger.debug(f"Found existing Gemeinde '{municipality_name}'")
-
- gemeinde_info = {
- "id": gemeinde.id,
- "label": gemeinde.label,
- "plz": gemeinde.plz
- }
-
- # Check if Gemeinde already has BZO documents
- existing_bzo = False
- logger.debug(f"Checking for existing BZO documents in Gemeinde '{gemeinde.label}' (has {len(gemeinde.dokumente) if gemeinde.dokumente else 0} documents)")
- if gemeinde.dokumente:
- for doc in gemeinde.dokumente:
- if (doc.label and ("BZO" in doc.label.upper() or "BAU UND ZONENORDNUNG" in doc.label.upper() or
- "PLAN D'AMÉNAGEMENT" in doc.label.upper() or "RÈGLEMENT DE CONSTRUCTION" in doc.label.upper() or
- "PIANO DI UTILIZZAZIONE" in doc.label.upper() or "REGOLAMENTO EDILIZIO" in doc.label.upper())) or \
- (doc.dokumentTyp and doc.dokumentTyp in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]):
- existing_bzo = True
- logger.info(f"Found existing BZO document: {doc.label} (ID: {doc.id})")
- bzo_documents.append({
- "id": doc.id,
- "label": doc.label,
- "dokumentTyp": doc.dokumentTyp.value if doc.dokumentTyp else None,
- "dokumentReferenz": doc.dokumentReferenz,
- "quelle": doc.quelle,
- "mimeType": doc.mimeType
- })
-
- if existing_bzo:
- logger.info(f"Gemeinde '{municipality_name}' already has {len(bzo_documents)} BZO document(s), skipping search")
-
- # If no BZO documents found, search and download
- if not existing_bzo:
- logger.info(f"No BZO documents found for {municipality_name}, searching with Tavily...")
-
- # Determine language
- language = _get_language_from_kanton(canton)
-
- # Generate search query
- search_query = _get_bzo_search_query(municipality_name, language)
- logger.debug(f"Tavily search query: {search_query}")
-
- # Initialize Tavily connector
- tavily = AiTavily()
-
- # Search with Tavily
- search_results = await tavily._search(
- query=search_query,
- maxResults=5,
- country="switzerland"
- )
-
- if search_results:
- # First, check for direct PDF URLs in search results
- pdf_urls = []
- html_urls = []
-
- for result in search_results:
- url = result.url.lower()
- # Check if it's a direct PDF link
- if url.endswith('.pdf') or '/pdf/' in url or url.endswith('/pdf'):
- if not any(skip in url for skip in ['.html', '.htm', '/page/', '/article/', '/news/']):
- pdf_urls.append(result.url)
- else:
- # It's an HTML page - we'll crawl it to find PDF links
- html_urls.append(result.url)
-
- # If no direct PDFs found, scrape HTML pages directly to find PDF links
- if not pdf_urls and html_urls:
- logger.info(f"No direct PDF links found, scraping {len(html_urls)} HTML pages to find PDF documents...")
-
- # Helper function to scrape HTML and find PDF links
- async def scrape_html_for_pdfs(url: str) -> List[str]:
- """Scrape an HTML page to find PDF links."""
- found_pdfs = []
- try:
- ssl_context = ssl.create_default_context()
- ssl_context.check_hostname = False
- ssl_context.verify_mode = ssl.CERT_NONE
- connector_aiohttp = aiohttp.TCPConnector(ssl=ssl_context)
-
- timeout = aiohttp.ClientTimeout(total=15, connect=5)
- headers = {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
- 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8'
- }
-
- async with aiohttp.ClientSession(timeout=timeout, headers=headers, connector=connector_aiohttp) as session:
- async with session.get(url, allow_redirects=True) as response:
- if response.status == 200:
- # Check Content-Type header first
- content_type = response.headers.get('Content-Type', '').lower()
-
- # Read first few bytes to check if it's a PDF
- raw_bytes = await response.read()
-
- # Check if it's actually a PDF by magic bytes
- if raw_bytes.startswith(b'%PDF'):
- found_pdfs.append(url)
- logger.info(f"Found direct PDF link (detected by magic bytes): {url}")
- return found_pdfs
-
- # If Content-Type says it's a PDF, add it
- if 'application/pdf' in content_type:
- found_pdfs.append(url)
- logger.info(f"Found direct PDF link (Content-Type): {url}")
- return found_pdfs
-
- # If URL ends with .pdf, it's likely a PDF
- if url.lower().endswith('.pdf'):
- found_pdfs.append(url)
- logger.info(f"Found direct PDF link (URL extension): {url}")
- return found_pdfs
-
- # Try to decode as text for HTML parsing
- try:
- # Try UTF-8 first
- html_content = raw_bytes.decode('utf-8')
- except UnicodeDecodeError:
- try:
- # Try ISO-8859-1 (common for German sites)
- html_content = raw_bytes.decode('iso-8859-1')
- except UnicodeDecodeError:
- try:
- # Try Windows-1252
- html_content = raw_bytes.decode('windows-1252')
- except UnicodeDecodeError:
- # If all else fails, skip this URL
- logger.warning(f"Could not decode content from {url} (not UTF-8, ISO-8859-1, or Windows-1252), skipping HTML parsing")
- return found_pdfs
-
- # Look for PDF links in various formats
- # Pattern 1: Direct PDF URLs
- pdf_pattern = r'https?://[^\s<>"\'\)]+\.pdf(?:\?[^\s<>"\'\)]*)?'
- found = re.findall(pdf_pattern, html_content, re.IGNORECASE)
-
- # Pattern 2: Relative PDF links (convert to absolute)
- relative_pattern = r'href=["\']([^"\']+\.pdf[^"\']*)["\']'
- relative_found = re.findall(relative_pattern, html_content, re.IGNORECASE)
-
- # Convert relative URLs to absolute
- base_url = f"{urlparse(url).scheme}://{urlparse(url).netloc}"
-
- for rel_url in relative_found:
- # Remove query params and fragments for cleaner URLs
- clean_url = rel_url.split('?')[0].split('#')[0]
- if clean_url.endswith('.pdf'):
- abs_url = urljoin(base_url, clean_url)
- if abs_url not in found:
- found.append(abs_url)
-
- # Pattern 3: Look in data attributes and other places
- data_pattern = r'data-[^=]*=["\']([^"\']+\.pdf[^"\']*)["\']'
- data_found = re.findall(data_pattern, html_content, re.IGNORECASE)
- for data_url in data_found:
- clean_url = data_url.split('?')[0].split('#')[0]
- if clean_url.endswith('.pdf'):
- abs_url = urljoin(base_url, clean_url) if not clean_url.startswith('http') else clean_url
- if abs_url not in found:
- found.append(abs_url)
-
- # Clean and deduplicate URLs
- for pdf_link in found:
- pdf_link = pdf_link.rstrip('.,;:!?)').strip()
- # Remove common tracking parameters
- if '?' in pdf_link:
- base, params = pdf_link.split('?', 1)
- # Keep only important params, remove tracking
- important_params = []
- for param in params.split('&'):
- if param.split('=')[0].lower() not in ['utm_source', 'utm_medium', 'utm_campaign', 'ref', 'fbclid', 'gclid']:
- important_params.append(param)
- if important_params:
- pdf_link = f"{base}?{'&'.join(important_params)}"
- else:
- pdf_link = base
-
- if pdf_link not in found_pdfs and pdf_link.startswith('http'):
- found_pdfs.append(pdf_link)
- logger.debug(f"Found PDF link on {url}: {pdf_link}")
-
- logger.info(f"Found {len(found_pdfs)} PDF links on {url}")
-
- except Exception as e:
- logger.debug(f"Error scraping {url} for PDFs: {e}", exc_info=True)
-
- return found_pdfs
-
- # Scrape HTML pages to find PDF links
- for html_url in html_urls[:5]: # Limit to first 5 URLs
- try:
- logger.debug(f"Scraping {html_url} to find PDF links...")
- found_pdfs = await scrape_html_for_pdfs(html_url)
- pdf_urls.extend(found_pdfs)
- except Exception as e:
- logger.warning(f"Error scraping {html_url} to find PDFs: {e}", exc_info=True)
- continue
-
- # Also check rawContent from search results for PDF links
- for result in search_results:
- if result.rawContent:
- pdf_pattern = r'https?://[^\s<>"\'\)]+\.pdf(?:\?[^\s<>"\'\)]*)?'
- found_pdfs = re.findall(pdf_pattern, result.rawContent, re.IGNORECASE)
- for pdf_link in found_pdfs:
- pdf_link = pdf_link.rstrip('.,;:!?)').strip()
- if pdf_link not in pdf_urls and pdf_link.startswith('http'):
- pdf_urls.append(pdf_link)
- logger.debug(f"Found PDF link in rawContent: {pdf_link}")
-
- if not pdf_urls:
- logger.warning(f"No PDF URLs found in Tavily results for {municipality_name}. Results were HTML pages, not direct PDF links.")
- logger.debug(f"Tavily returned URLs: {[r.url for r in search_results]}")
-
- logger.info(f"Found {len(pdf_urls)} potential PDF documents for {municipality_name}")
-
- # Helper function to download a single PDF
- async def download_pdf(pdf_url: str) -> Optional[bytes]:
- """Download a PDF from a URL with retry logic."""
- max_retries = 3
- retry_delay = 2
-
- for attempt in range(max_retries):
- try:
- if attempt > 0:
- headers = {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
- 'Accept': '*/*'
- }
- else:
- headers = {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
- 'Accept': 'application/pdf,application/octet-stream,*/*',
- 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
- 'Accept-Encoding': 'gzip, deflate, br',
- 'Connection': 'keep-alive',
- 'Upgrade-Insecure-Requests': '1'
- }
-
- # Create SSL context that doesn't verify certificates (for development)
- ssl_context = ssl.create_default_context()
- ssl_context.check_hostname = False
- ssl_context.verify_mode = ssl.CERT_NONE
-
- # Create connector with SSL context
- connector = aiohttp.TCPConnector(ssl=ssl_context)
-
- timeout = aiohttp.ClientTimeout(total=30, connect=10)
- async with aiohttp.ClientSession(timeout=timeout, headers=headers, connector=connector) as session:
- async with session.get(pdf_url, allow_redirects=True) as response:
- if response.status == 200:
- # Check content-type header first
- content_type = response.headers.get('Content-Type', '').lower()
- if 'text/html' in content_type or 'text/xml' in content_type:
- logger.warning(f"URL {pdf_url} returned HTML content (Content-Type: {content_type}), skipping")
- raise Exception("Server returned HTML content instead of PDF")
-
- pdf_content = await response.read()
-
- if not pdf_content or len(pdf_content) < 100:
- raise Exception("Downloaded file is too small or empty")
-
- # Verify it's actually a PDF
- if not pdf_content.startswith(b'%PDF'):
- if pdf_content.startswith(b'<') or pdf_content.startswith(b' 1:
- file_name = f"BZO_{safe_name}_{idx + 1}.pdf"
- doc_label = f"{base_doc_label} ({idx + 1})"
- else:
- file_name = f"BZO_{safe_name}.pdf"
- doc_label = base_doc_label
-
- # Store file using ComponentObjects
- try:
- file_item = componentInterface.createFile(
- name=file_name,
- mimeType="application/pdf",
- content=pdf_content
- )
-
- componentInterface.createFileData(file_item.id, pdf_content)
- logger.info(f"Stored file {file_name} with ID {file_item.id}")
- except Exception as e:
- logger.error(f"Error storing file {file_name}: {str(e)}", exc_info=True)
- continue
-
- # Create Dokument record
- dokument = Dokument(
- mandateId=currentUser.mandateId,
- label=doc_label,
- versionsbezeichnung="Aktuell",
- dokumentTyp=DokumentTyp.GEMEINDE_BZO_AKTUELL,
- dokumentReferenz=file_item.id,
- quelle=pdf_url,
- mimeType="application/pdf",
- kategorienTags=["BZO", "Bauordnung", municipality_name]
- )
-
- # Create Dokument record
- created_dokument = realEstateInterface.createDokument(dokument)
- logger.info(f"Created Dokument record with ID {created_dokument.id}")
-
- current_dokumente.append(created_dokument)
-
- # Add to response
- bzo_documents.append({
- "id": created_dokument.id,
- "label": created_dokument.label,
- "dokumentTyp": created_dokument.dokumentTyp.value if created_dokument.dokumentTyp else None,
- "dokumentReferenz": created_dokument.dokumentReferenz,
- "quelle": created_dokument.quelle,
- "mimeType": created_dokument.mimeType
- })
-
- except Exception as e:
- logger.error(f"Error processing PDF {pdf_url}: {str(e)}", exc_info=True)
- continue
-
- # Update Gemeinde with new dokumente
- if bzo_documents:
- updated_gemeinde = realEstateInterface.updateGemeinde(
- gemeinde.id,
- {"dokumente": current_dokumente}
- )
- if updated_gemeinde:
- logger.info(f"Successfully created {len(bzo_documents)} BZO document(s) for {municipality_name}")
- else:
- logger.warning(f"No search results found for {municipality_name}")
-
- except Exception as e:
- logger.error(f"Error fetching BZO documents for {municipality_name}: {e}", exc_info=True)
- # Continue without documents - don't fail the request
- elif fetch_documents:
- if not municipality_name:
- logger.warning("fetch_documents=true but municipality_name is not available, skipping document fetch")
- elif not canton:
- logger.warning("fetch_documents=true but canton is not available, skipping document fetch")
-
- # Add Gemeinde and documents to response if available
- logger.debug(f"Adding to response: gemeinde_info={gemeinde_info is not None}, bzo_documents count={len(bzo_documents)}")
- if gemeinde_info:
- response_data["gemeinde"] = gemeinde_info
- logger.debug(f"Added gemeinde_info to response: {gemeinde_info}")
- if bzo_documents:
- response_data["documents"] = bzo_documents
- logger.info(f"Added {len(bzo_documents)} BZO documents to response")
- else:
- logger.debug("No BZO documents to add to response")
-
- return response_data
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error searching parcel: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error searching parcel: {str(e)}"
- )
-
-
-@router.post("/projekt/{projekt_id}/add-parcel", response_model=Dict[str, Any])
-@limiter.limit("60/minute")
-async def add_parcel_to_project(
- request: Request,
- projekt_id: str = Path(..., description="Projekt ID"),
- body: Dict[str, Any] = Body(...),
- currentUser: User = Depends(getCurrentUser)
-) -> Dict[str, Any]:
- """
- Add a parcel to an existing project.
-
- This endpoint can either:
- 1. Link an existing Parzelle to the Projekt
- 2. Create a new Parzelle from location data and link it
-
- Request Body:
- Option 1 - Link existing parcel:
- {
- "parcelId": "existing-parcel-id"
- }
-
- Option 2 - Create new parcel from location:
- {
- "location": "Hauptstrasse 42, 8000 Zürich"
- }
-
- Option 3 - Create new parcel with custom data:
- {
- "parcelData": {
- "label": "Parzelle 123",
- "strasseNr": "Hauptstrasse 42",
- "plz": "8000",
- "bauzone": "W3",
- ...
- }
- }
-
- Headers:
- - X-CSRF-Token: CSRF token (required for security)
-
- Returns:
- {
- "projekt": {...}, // Updated Projekt
- "parzelle": {...} // Parcel that was added
- }
- """
- try:
- # Validate CSRF token
- csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
- if not csrf_token:
- logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
- )
-
- # Validate CSRF token format
- if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
- try:
- int(csrf_token, 16)
- except ValueError:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- logger.info(f"Adding parcel to project {projekt_id} for user {currentUser.id} (mandate: {currentUser.mandateId})")
-
- # Get interface
- realEstateInterface = getRealEstateInterface(currentUser)
-
- # Fetch existing Projekt
- projekte = realEstateInterface.getProjekte(
- recordFilter={"id": projekt_id, "mandateId": currentUser.mandateId}
- )
- if not projekte:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Projekt {projekt_id} not found"
- )
- projekt = projekte[0]
-
- # Determine which option was used
- parcel_id = body.get("parcelId")
- location = body.get("location")
- parcel_data_dict = body.get("parcelData")
-
- parzelle = None
-
- # Option 1: Link existing parcel
- if parcel_id:
- logger.info(f"Linking existing parcel {parcel_id}")
- parcels = realEstateInterface.getParzellen(
- recordFilter={"id": parcel_id, "mandateId": currentUser.mandateId}
- )
- if not parcels:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Parzelle {parcel_id} not found"
- )
- parzelle = parcels[0]
-
- # Option 2: Create from location
- elif location:
- logger.info(f"Creating parcel from location: {location}")
-
- # Initialize connector and search for parcel
- connector = SwissTopoMapServerConnector()
- parcel_data = await connector.search_parcel(location)
-
- if not parcel_data:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"No parcel found at location: {location}"
- )
-
- # Extract attributes
- extracted_attributes = connector.extract_parcel_attributes(parcel_data)
- attributes = parcel_data.get("attributes", {})
-
- # Create Parzelle
- parzelle_create_data = {
- "mandateId": currentUser.mandateId,
- "label": extracted_attributes.get("label") or attributes.get("number") or "Unknown",
- "parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [],
- "eigentuemerschaft": None,
- "strasseNr": location if not location.replace(",", "").replace(".", "").replace(" ", "").isdigit() else None,
- "plz": None,
- "perimeter": extracted_attributes.get("perimeter"),
- "baulinie": None,
- "kontextGemeinde": None,
- "bauzone": None,
- "az": None,
- "bz": None,
- "vollgeschossZahl": None,
- "anrechenbarDachgeschoss": None,
- "anrechenbarUntergeschoss": None,
- "gebaeudehoeheMax": None,
- "regelnGrenzabstand": [],
- "regelnMehrlaengenzuschlag": [],
- "regelnMehrhoehenzuschlag": [],
- "parzelleBebaut": None,
- "parzelleErschlossen": None,
- "parzelleHanglage": None,
- "laermschutzzone": None,
- "hochwasserschutzzone": None,
- "grundwasserschutzzone": None,
- "parzellenNachbarschaft": [],
- "dokumente": [],
- "kontextInformationen": [
- Kontext(
- thema="Swiss Topo Data",
- inhalt=json.dumps({
- "egrid": attributes.get("egris_egrid"),
- "identnd": attributes.get("identnd"),
- "canton": attributes.get("ak"),
- "municipality_code": attributes.get("bfsnr"),
- "geoportal_url": attributes.get("geoportal_url")
- }, ensure_ascii=False)
- )
- ]
- }
-
- parzelle_instance = Parzelle(**parzelle_create_data)
- parzelle = realEstateInterface.createParzelle(parzelle_instance)
-
- # Option 3: Create from custom data
- elif parcel_data_dict:
- logger.info(f"Creating parcel from custom data")
- parcel_data_dict["mandateId"] = currentUser.mandateId
- parzelle_instance = Parzelle(**parcel_data_dict)
- parzelle = realEstateInterface.createParzelle(parzelle_instance)
-
- else:
- raise ValueError("One of 'parcelId', 'location', or 'parcelData' is required")
-
- # Add parcel to project
- if parzelle not in projekt.parzellen:
- projekt.parzellen.append(parzelle)
-
- # Update projekt perimeter if needed (use first parcel's perimeter)
- if not projekt.perimeter and parzelle.perimeter:
- projekt.perimeter = parzelle.perimeter
-
- # Update Projekt
- updated_projekt = realEstateInterface.updateProjekt(projekt)
-
- logger.info(f"Added Parzelle {parzelle.id} to Projekt {projekt_id}")
-
- return {
- "projekt": updated_projekt.model_dump(),
- "parzelle": parzelle.model_dump()
- }
-
- except ValueError as e:
- logger.error(f"Validation error in add_parcel_to_project: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Validation error: {str(e)}"
- )
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error adding parcel to project: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error adding parcel to project: {str(e)}"
- )
-
-
-@router.get("/bzo-information", response_model=Dict[str, Any])
-@limiter.limit("30/minute")
-async def get_bzo_information(
- request: Request,
- gemeinde: str = Query(..., description="Gemeinde name or ID"),
- bauzone: str = Query(..., description="Bauzone code (e.g., W3, W2/30)"),
- total_area_m2: Optional[float] = Query(None, description="Total parcel area (m²) for Machbarkeitsstudie"),
- currentUser: User = Depends(getCurrentUser)
-) -> Dict[str, Any]:
- """
- Extract BZO information from PDF documents for a specific Bauzone in a Gemeinde.
-
- Uses the BZO extraction pipeline to extract content from BZO PDF documents for the
- specified Gemeinde, then uses AI to search for relevant information specific
- to the specified Bauzone.
-
- The workflow:
- 1. Finds BZO documents for the Gemeinde (by name or ID)
- 2. Extracts content from PDFs using the BZO extraction pipeline
- 3. Filters rules, zones, and articles by Bauzone
- 4. Uses AI to generate a summary and find relevant information
-
- Query Parameters:
- - gemeinde: Gemeinde name (e.g., "Zürich") or ID
- - bauzone: Bauzone code (e.g., "W3", "W2/30", "Z3")
-
- Headers:
- - X-CSRF-Token: CSRF token (required for security)
-
- Returns:
- {
- "bauzone": "W3",
- "gemeinde": {
- "id": "...",
- "label": "...",
- "plz": "..."
- },
- "extracted_content": {
- "zones": [...], // Zone information filtered by Bauzone
- "rules": [...], // Rules filtered by Bauzone
- "articles": [...], // Articles filtered by Bauzone
- "total_zones": N,
- "total_rules": N,
- "total_articles": N
- },
- "ai_summary": "...", // AI-generated summary
- "relevant_rules": [...], // Rules specifically for this Bauzone
- "documents_processed": [ // List of document IDs processed
- {
- "id": "...",
- "label": "...",
- "dokumentTyp": "..."
- }
- ],
- "errors": [...],
- "warnings": [...]
- }
-
- Examples:
- - GET /api/realestate/bzo-information?gemeinde=Zürich&bauzone=W3
- - GET /api/realestate/bzo-information?gemeinde=Uster&bauzone=W2/30
-
- Raises:
- - 404: Gemeinde not found
- - 404: No BZO documents found for Gemeinde
- - 500: Error during extraction or processing
- """
- try:
- # Validate CSRF token
- csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
- if not csrf_token:
- logger.warning(f"CSRF token missing for GET /api/realestate/bzo-information from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
- )
-
- # Basic CSRF token format validation
- if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
- logger.warning(f"Invalid CSRF token format for GET /api/realestate/bzo-information from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- # Validate token is hex string
- try:
- int(csrf_token, 16)
- except ValueError:
- logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/bzo-information from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {currentUser.mandateId})")
-
- # Call the feature function
- result = await extract_bzo_information(
- currentUser=currentUser,
- gemeinde=gemeinde,
- bauzone=bauzone,
- total_area_m2=total_area_m2,
- )
-
- return result
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}': {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error extracting BZO information: {str(e)}"
- )
-
diff --git a/modules/routes/routeRealEstateScraping.py b/modules/routes/routeRealEstateScraping.py
deleted file mode 100644
index abb54299..00000000
--- a/modules/routes/routeRealEstateScraping.py
+++ /dev/null
@@ -1,881 +0,0 @@
-"""
-Real Estate scraping routes for the backend API.
-Implements endpoints for scraping real estate data from external sources.
-"""
-
-import logging
-import json
-import aiohttp
-import asyncio
-from typing import Optional, Dict, Any
-from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, status
-
-# Import auth modules
-from modules.auth import limiter, getCurrentUser
-
-# Import models
-from modules.datamodels.datamodelUam import User
-from modules.datamodels.datamodelRealEstate import (
- Gemeinde,
- Kanton,
- Dokument,
- Kontext,
- DokumentTyp,
-)
-
-# Import interfaces
-from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
-from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
-
-# Import scraping script
-from modules.features.realEstate.scrapeSwissTopo import scrape_switzerland
-
-# Import Swiss Topo MapServer connector
-from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
-from modules.connectors.connectorOerebWfs import OerebWfsConnector
-
-# Import Tavily connector for BZO document search
-from modules.aicore.aicorePluginTavily import AiTavily
-from modules.shared.i18nRegistry import apiRouteContext
-routeApiMsg = apiRouteContext("routeRealEstateScraping")
-
-# Configure logger
-logger = logging.getLogger(__name__)
-
-# Create router for real estate scraping endpoints
-router = APIRouter(
- prefix="/api/realestate",
- tags=["Real Estate Scraping"],
- responses={
- 404: {"description": "Not found"},
- 400: {"description": "Bad request"},
- 401: {"description": "Unauthorized"},
- 403: {"description": "Forbidden"},
- 500: {"description": "Internal server error"}
- }
-)
-
-
-@router.post("/scrape-switzerland", response_model=Dict[str, Any])
-@limiter.limit("5/hour") # Limit to 5 requests per hour (scraping is resource-intensive)
-async def scrape_switzerland_route(
- request: Request,
- body: Dict[str, Any] = Body(..., description="Scraping parameters"),
- currentUser: User = Depends(getCurrentUser)
-) -> Dict[str, Any]:
- """
- Scrape Kanton Zürich systematically using Swiss Topo connector and save parcel data to database.
-
- This endpoint divides Kanton Zürich into a grid and queries parcels at each grid point,
- then deduplicates and saves unique parcels to the database. For each parcel, it also
- queries the ÖREB WFS service to retrieve bauzone information.
-
- **WARNING**: This is a resource-intensive operation that may take a long time
- and make many API requests. Use with caution.
-
- Request Body:
- {
- "grid_size": 500.0, // Grid cell size in meters (default: 500m)
- "max_concurrent": 50, // Maximum concurrent API requests (default: 50)
- "batch_size": 100 // Number of parcels to process before saving (default: 100)
- }
-
- Headers:
- - X-CSRF-Token: CSRF token (required for security)
-
- Returns:
- {
- "success": true,
- "stats": {
- "total_queries": 1234,
- "successful_queries": 1200,
- "failed_queries": 34,
- "unique_parcels_found": 500,
- "parcels_saved": 450,
- "parcels_skipped": 50,
- "error_count": 5,
- "errors": [...]
- }
- }
-
- Example:
- - POST /api/realestate/scrape-switzerland
- Body: {"grid_size": 1000.0, "max_concurrent": 5, "batch_size": 50}
- """
- try:
- # Validate CSRF token
- csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
- if not csrf_token:
- logger.warning(f"CSRF token missing for POST /api/realestate/scrape-switzerland from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
- )
-
- # Basic CSRF token format validation
- if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
- logger.warning(f"Invalid CSRF token format for POST /api/realestate/scrape-switzerland from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- # Validate token is hex string
- try:
- int(csrf_token, 16)
- except ValueError:
- logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/scrape-switzerland from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- # Extract parameters from body with defaults
- grid_size = body.get("grid_size", 500.0)
- max_concurrent = body.get("max_concurrent", 50)
- batch_size = body.get("batch_size", 100)
-
- # Validate parameters
- if grid_size <= 0 or grid_size > 10000:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=routeApiMsg("grid_size must be between 0 and 10000 meters")
- )
-
- if max_concurrent <= 0 or max_concurrent > 200:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=routeApiMsg("max_concurrent must be between 1 and 200")
- )
-
- if batch_size <= 0 or batch_size > 1000:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=routeApiMsg("batch_size must be between 1 and 1000")
- )
-
- logger.info(
- f"Starting Switzerland scraping for user {currentUser.id} (mandate: {currentUser.mandateId}) "
- f"with grid_size={grid_size}, max_concurrent={max_concurrent}, batch_size={batch_size}"
- )
-
- # Run scraping operation
- result = await scrape_switzerland(
- current_user=currentUser,
- grid_size=grid_size,
- max_concurrent=max_concurrent,
- batch_size=batch_size
- )
-
- logger.info(
- f"Scraping completed for user {currentUser.id}: "
- f"{result['stats']['parcels_saved']} parcels saved"
- )
-
- return result
-
- except HTTPException:
- raise
- except ValueError as e:
- logger.error(f"Validation error in scrape_switzerland_route: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Validation error: {str(e)}"
- )
- except Exception as e:
- logger.error(f"Error scraping Switzerland: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error scraping Switzerland: {str(e)}"
- )
-
-
-@router.get("/gemeinden", response_model=Dict[str, Any])
-@limiter.limit("60/minute")
-async def get_all_gemeinden(
- request: Request,
- only_current: bool = Query(True, description="Only return current municipalities (exclude historical)"),
- currentUser: User = Depends(getCurrentUser)
-) -> Dict[str, Any]:
- """
- Fetch all Gemeinden (municipalities) from the Swiss Topo MapServer connector
- and save them to the database.
-
- This endpoint:
- 1. Fetches all Swiss municipalities from the Swiss Federal Office of Topography
- 2. Saves them to the database (skipping duplicates based on BFS number)
- 3. Creates Kantone (cantons) as needed
- 4. Returns statistics about the import operation
-
- Query Parameters:
- - only_current: If True, only return current municipalities (default: True).
- If False, return all municipalities including historical ones.
-
- Headers:
- - X-CSRF-Token: CSRF token (required for security)
-
- Returns:
- {
- "gemeinden": [
- {
- "id": "uuid",
- "mandateId": "uuid",
- "label": "Bern",
- "id_kanton": "uuid",
- "kontextInformationen": [...],
- ...
- },
- ...
- ],
- "count": 2162,
- "stats": {
- "gemeinden_created": 2100,
- "gemeinden_skipped": 62,
- "kantone_created": 26,
- "error_count": 0,
- "errors": []
- }
- }
-
- Example:
- - GET /api/realestate/gemeinden
- - GET /api/realestate/gemeinden?only_current=false
- """
- try:
- # Validate CSRF token
- csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
- if not csrf_token:
- logger.warning(f"CSRF token missing for GET /api/realestate/gemeinden from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
- )
-
- # Basic CSRF token format validation
- if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
- logger.warning(f"Invalid CSRF token format for GET /api/realestate/gemeinden from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- # Validate token is hex string
- try:
- int(csrf_token, 16)
- except ValueError:
- logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/gemeinden from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- logger.info(f"Fetching all Gemeinden for user {currentUser.id} (mandate: {currentUser.mandateId}), only_current={only_current}")
-
- # Initialize connectors and fetch all gemeinden
- oereb_connector = OerebWfsConnector()
- connector = SwissTopoMapServerConnector(oereb_connector=oereb_connector)
- gemeinden_data = await connector.get_all_gemeinden(only_current=only_current)
-
- # Get interface for database operations
- realEstateInterface = getRealEstateInterface(currentUser)
-
- # Statistics
- gemeinden_created = 0
- gemeinden_skipped = 0
- kantone_created = 0
- errors = []
-
- # Cache for Kanton UUIDs
- kanton_cache: Dict[str, str] = {}
-
- # Helper function to find Gemeinde by BFS number
- def find_gemeinde_by_bfs_nummer(bfs_nummer: str) -> Optional[Gemeinde]:
- """Find existing Gemeinde by BFS number (stored in kontextInformationen)."""
- try:
- gemeinden = realEstateInterface.getGemeinden(
- recordFilter={"mandateId": currentUser.mandateId}
- )
-
- for gemeinde in gemeinden:
- # Check kontextInformationen for bfs_nummer
- for kontext in gemeinde.kontextInformationen:
- try:
- kontext_data = json.loads(kontext.inhalt) if isinstance(kontext.inhalt, str) else kontext.inhalt
- if isinstance(kontext_data, dict):
- if str(kontext_data.get("bfs_nummer")) == str(bfs_nummer):
- return gemeinde
- except (json.JSONDecodeError, AttributeError):
- continue
-
- return None
- except Exception as e:
- logger.error(f"Error finding Gemeinde by BFS number {bfs_nummer}: {e}", exc_info=True)
- return None
-
- # Helper function to get or create Kanton
- def get_or_create_kanton(kanton_abk: str) -> Optional[str]:
- """Get or create a Kanton by abbreviation."""
- nonlocal kantone_created, errors
-
- if not kanton_abk:
- return None
-
- # Check cache first
- if kanton_abk in kanton_cache:
- return kanton_cache[kanton_abk]
-
- # Check if exists
- kantone = realEstateInterface.getKantone(
- recordFilter={
- "mandateId": currentUser.mandateId,
- "abk": kanton_abk
- }
- )
-
- if kantone:
- kanton_cache[kanton_abk] = kantone[0].id
- return kantone[0].id
-
- # Create new Kanton
- try:
- # Map common abbreviations to full names
- kanton_names = {
- "AG": "Aargau", "AI": "Appenzell Innerrhoden", "AR": "Appenzell Ausserrhoden",
- "BE": "Bern", "BL": "Basel-Landschaft", "BS": "Basel-Stadt",
- "FR": "Freiburg", "GE": "Genf", "GL": "Glarus", "GR": "Graubünden",
- "JU": "Jura", "LU": "Luzern", "NE": "Neuenburg", "NW": "Nidwalden",
- "OW": "Obwalden", "SG": "St. Gallen", "SH": "Schaffhausen", "SO": "Solothurn",
- "SZ": "Schwyz", "TG": "Thurgau", "TI": "Tessin", "UR": "Uri",
- "VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich"
- }
-
- kanton_label = kanton_names.get(kanton_abk, kanton_abk)
-
- kanton = Kanton(
- mandateId=currentUser.mandateId,
- label=kanton_label,
- abk=kanton_abk
- )
-
- created_kanton = realEstateInterface.createKanton(kanton)
- if created_kanton and created_kanton.id:
- kanton_cache[kanton_abk] = created_kanton.id
- kantone_created += 1
- logger.info(f"Created new Kanton: {kanton_label} ({kanton_abk})")
- return created_kanton.id
- except Exception as e:
- error_msg = f"Error creating Kanton {kanton_abk}: {e}"
- logger.error(error_msg, exc_info=True)
- errors.append(error_msg)
-
- return None
-
- # Process each gemeinde and save to database
- saved_gemeinden = []
- for gemeinde_data in gemeinden_data:
- try:
- gemeinde_name = gemeinde_data.get("name")
- bfs_nummer = gemeinde_data.get("bfs_nummer")
- kanton_abk = gemeinde_data.get("kanton")
-
- if not gemeinde_name or not bfs_nummer:
- logger.warning(f"Skipping Gemeinde with missing data: {gemeinde_data}")
- gemeinden_skipped += 1
- continue
-
- # Check if Gemeinde already exists
- existing_gemeinde = find_gemeinde_by_bfs_nummer(str(bfs_nummer))
- if existing_gemeinde:
- logger.debug(f"Gemeinde {gemeinde_name} (BFS: {bfs_nummer}) already exists, skipping")
- gemeinden_skipped += 1
- saved_gemeinden.append(existing_gemeinde.model_dump() if hasattr(existing_gemeinde, 'model_dump') else existing_gemeinde)
- continue
-
- # Get or create Kanton
- kanton_id = get_or_create_kanton(kanton_abk) if kanton_abk else None
-
- # Create new Gemeinde
- gemeinde = Gemeinde(
- mandateId=currentUser.mandateId,
- label=gemeinde_name,
- id_kanton=kanton_id,
- kontextInformationen=[
- Kontext(
- thema="BFS Nummer",
- inhalt=json.dumps({"bfs_nummer": bfs_nummer}, ensure_ascii=False)
- )
- ]
- )
-
- created_gemeinde = realEstateInterface.createGemeinde(gemeinde)
- if created_gemeinde and created_gemeinde.id:
- gemeinden_created += 1
- logger.info(f"Created new Gemeinde: {gemeinde_name} (BFS: {bfs_nummer})")
- saved_gemeinden.append(created_gemeinde.model_dump() if hasattr(created_gemeinde, 'model_dump') else created_gemeinde)
- else:
- error_msg = f"Failed to create Gemeinde {gemeinde_name} (BFS: {bfs_nummer})"
- logger.error(error_msg)
- errors.append(error_msg)
- gemeinden_skipped += 1
-
- except Exception as e:
- error_msg = f"Error processing Gemeinde {gemeinde_data.get('name', 'Unknown')}: {str(e)}"
- logger.error(error_msg, exc_info=True)
- errors.append(error_msg)
- gemeinden_skipped += 1
-
- logger.info(
- f"Gemeinden import completed: {gemeinden_created} created, "
- f"{gemeinden_skipped} skipped, {kantone_created} Kantone created"
- )
-
- return {
- "gemeinden": saved_gemeinden,
- "count": len(saved_gemeinden),
- "stats": {
- "gemeinden_created": gemeinden_created,
- "gemeinden_skipped": gemeinden_skipped,
- "kantone_created": kantone_created,
- "error_count": len(errors),
- "errors": errors[:10] # Return first 10 errors
- }
- }
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error fetching all Gemeinden: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error fetching Gemeinden: {str(e)}"
- )
-
-
-def _get_language_from_kanton(kanton_abk: Optional[str]) -> str:
- """
- Determine language (German/French/Italian) based on Kanton abbreviation.
-
- Args:
- kanton_abk: Kanton abbreviation (e.g., 'ZH', 'VD', 'TI')
-
- Returns:
- Language code: 'de' (German), 'fr' (French), or 'it' (Italian)
- """
- if not kanton_abk:
- return 'de' # Default to German
-
- # French-speaking cantons
- french_cantons = {'VD', 'GE', 'NE', 'JU'}
- # Italian-speaking canton
- italian_cantons = {'TI'}
-
- kanton_upper = kanton_abk.upper()
- if kanton_upper in french_cantons:
- return 'fr'
- elif kanton_upper in italian_cantons:
- return 'it'
- else:
- return 'de' # Default to German
-
-
-def _get_bzo_search_query(gemeinde_label: str, language: str) -> str:
- """
- Generate language-specific BZO search query for a Gemeinde.
-
- Args:
- gemeinde_label: Name of the Gemeinde
- language: Language code ('de', 'fr', 'it')
-
- Returns:
- Search query string
- """
- if language == 'fr':
- # French: Plan d'aménagement local or Règlement de construction
- return f"Plan d'aménagement local {gemeinde_label} OR Règlement de construction {gemeinde_label}"
- elif language == 'it':
- # Italian: Piano di utilizzazione or Regolamento edilizio
- return f"Piano di utilizzazione {gemeinde_label} OR Regolamento edilizio {gemeinde_label}"
- else:
- # German: Bau und Zonenordnung
- return f"Bau und Zonenordnung {gemeinde_label}"
-
-
-@router.post("/gemeinden/fetch-bzo-documents", response_model=Dict[str, Any])
-@limiter.limit("10/hour") # Resource-intensive operation
-async def fetch_bzo_documents(
- request: Request,
- currentUser: User = Depends(getCurrentUser)
-) -> Dict[str, Any]:
- """
- Search for and download Bau und Zonenordnung (BZO) documents for all Gemeinden.
-
- This endpoint:
- 1. Fetches all Gemeinden from the database
- 2. For each Gemeinde, determines language based on Kanton
- 3. Uses Tavily search to find BZO documents (up to 5 results)
- 4. Downloads all PDF files found and stores them with content
- 5. Creates Dokument records for each PDF and links them to Gemeinde's dokumente field
- 6. Skips Gemeinden that already have BZO documents
-
- Note: If Tavily returns multiple PDF results, all of them will be downloaded
- and saved as separate Dokument records.
-
- Headers:
- - X-CSRF-Token: CSRF token (required for security)
-
- Returns:
- {
- "success": true,
- "stats": {
- "gemeinden_processed": 100,
- "documents_created": 85,
- "documents_skipped": 15,
- "errors": []
- },
- "results": [
- {
- "gemeinde_id": "...",
- "gemeinde_label": "Zürich",
- "status": "created|skipped|error",
- "dokument_ids": ["...", "..."], // List of created document IDs (can be multiple)
- "error": null
- }
- ]
- }
- """
- try:
- # Validate CSRF token
- csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
- if not csrf_token:
- logger.warning(f"CSRF token missing for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
- )
-
- # Basic CSRF token format validation
- if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
- logger.warning(f"Invalid CSRF token format for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- # Validate token is hex string
- try:
- int(csrf_token, 16)
- except ValueError:
- logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}")
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=routeApiMsg("Invalid CSRF token format")
- )
-
- logger.info(f"Starting BZO document fetch for user {currentUser.id} (mandate: {currentUser.mandateId})")
-
- # Get interfaces
- realEstateInterface = getRealEstateInterface(currentUser)
- componentInterface = getComponentInterface(currentUser)
-
- # Initialize Tavily connector
- tavily = AiTavily()
-
- # Get all Gemeinden
- gemeinden = realEstateInterface.getGemeinden(
- recordFilter={"mandateId": currentUser.mandateId}
- )
-
- logger.info(f"Found {len(gemeinden)} Gemeinden to process")
-
- # Statistics
- stats = {
- "gemeinden_processed": 0,
- "documents_created": 0,
- "documents_skipped": 0,
- "errors": []
- }
- results = []
-
- # Process each Gemeinde
- for gemeinde in gemeinden:
- gemeinde_result = {
- "gemeinde_id": gemeinde.id,
- "gemeinde_label": gemeinde.label,
- "status": None,
- "dokument_ids": [], # Changed to list to support multiple documents
- "error": None
- }
-
- try:
- stats["gemeinden_processed"] += 1
-
- # Check if Gemeinde already has a BZO document
- existing_bzo = False
- if gemeinde.dokumente:
- for doc in gemeinde.dokumente:
- # Check if it's a BZO document by label or dokumentTyp
- if (doc.label and ("BZO" in doc.label.upper() or "BAU UND ZONENORDNUNG" in doc.label.upper() or
- "PLAN D'AMÉNAGEMENT" in doc.label.upper() or "RÈGLEMENT DE CONSTRUCTION" in doc.label.upper() or
- "PIANO DI UTILIZZAZIONE" in doc.label.upper() or "REGOLAMENTO EDILIZIO" in doc.label.upper())) or \
- (doc.dokumentTyp and doc.dokumentTyp in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]):
- existing_bzo = True
- break
-
- if existing_bzo:
- logger.debug(f"Gemeinde {gemeinde.label} already has BZO document, skipping")
- gemeinde_result["status"] = "skipped"
- stats["documents_skipped"] += 1
- results.append(gemeinde_result)
- continue
-
- # Get Kanton to determine language
- kanton_abk = None
- if gemeinde.id_kanton:
- kanton = realEstateInterface.getKanton(gemeinde.id_kanton)
- if kanton:
- kanton_abk = kanton.abk
-
- # Determine language
- language = _get_language_from_kanton(kanton_abk)
-
- # Generate search query
- search_query = _get_bzo_search_query(gemeinde.label, language)
- logger.info(f"Searching for BZO document for {gemeinde.label} (language: {language}) with query: {search_query}")
-
- # Search with Tavily using the private _search method
- search_results = await tavily._search(
- query=search_query,
- maxResults=5,
- country="switzerland"
- )
-
- if not search_results:
- logger.warning(f"No search results found for {gemeinde.label}")
- gemeinde_result["status"] = "error"
- gemeinde_result["error"] = "No search results found"
- stats["errors"].append(f"{gemeinde.label}: No search results found")
- results.append(gemeinde_result)
- continue
-
- # Find all PDF URLs from search results
- pdf_urls = []
- for result in search_results:
- url = result.url.lower()
- if url.endswith('.pdf') or 'pdf' in url:
- pdf_urls.append(result.url)
-
- # If no PDF URLs found, try to use all results (they might be PDFs even without .pdf extension)
- if not pdf_urls:
- pdf_urls = [result.url for result in search_results]
- logger.info(f"No explicit PDF URLs found for {gemeinde.label}, trying all {len(pdf_urls)} results")
-
- logger.info(f"Found {len(pdf_urls)} potential PDF documents for {gemeinde.label}")
-
- # Helper function to download a single PDF
- async def download_pdf(pdf_url: str) -> Optional[bytes]:
- """Download a PDF from a URL with retry logic."""
- max_retries = 3
- retry_delay = 2
-
- for attempt in range(max_retries):
- try:
- # Create headers - use minimal headers on retry after 406 error
- if attempt > 0:
- # Minimal headers for retry
- headers = {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
- 'Accept': '*/*'
- }
- else:
- # Full headers for first attempt
- headers = {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
- 'Accept': 'application/pdf,application/octet-stream,*/*',
- 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
- 'Accept-Encoding': 'gzip, deflate, br',
- 'Connection': 'keep-alive',
- 'Upgrade-Insecure-Requests': '1'
- }
-
- timeout = aiohttp.ClientTimeout(total=30, connect=10)
- async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
- async with session.get(pdf_url, allow_redirects=True) as response:
- if response.status == 200:
- pdf_content = await response.read()
-
- if not pdf_content or len(pdf_content) < 100: # Minimum size check
- raise Exception("Downloaded file is too small or empty")
-
- # Verify it's actually a PDF (check PDF magic bytes)
- if not pdf_content.startswith(b'%PDF'):
- # Check if it's HTML (common error page)
- if pdf_content.startswith(b'<') or pdf_content.startswith(b' 1:
- file_name = f"BZO_{safe_name}_{idx + 1}.pdf"
- doc_label = f"{base_doc_label} ({idx + 1})"
- else:
- file_name = f"BZO_{safe_name}.pdf"
- doc_label = base_doc_label
-
- # Store file using ComponentObjects
- try:
- file_item = componentInterface.createFile(
- name=file_name,
- mimeType="application/pdf",
- content=pdf_content
- )
-
- # Store file data
- componentInterface.createFileData(file_item.id, pdf_content)
-
- logger.info(f"Stored file {file_name} with ID {file_item.id} for {gemeinde.label}")
- except Exception as e:
- logger.error(f"Error storing file {file_name} for {gemeinde.label}: {str(e)}", exc_info=True)
- stats["errors"].append(f"{gemeinde.label}: File storage failed for {pdf_url} - {str(e)}")
- continue
-
- # Create Dokument record
- dokument = Dokument(
- mandateId=currentUser.mandateId,
- label=doc_label,
- versionsbezeichnung="Aktuell",
- dokumentTyp=DokumentTyp.GEMEINDE_BZO_AKTUELL,
- dokumentReferenz=file_item.id, # FileId from ComponentObjects
- quelle=pdf_url, # Original URL
- mimeType="application/pdf",
- kategorienTags=["BZO", "Bauordnung", gemeinde.label]
- )
-
- # Create Dokument record in the Dokument table
- created_dokument = realEstateInterface.createDokument(dokument)
- logger.info(f"Created Dokument record with ID {created_dokument.id} for {gemeinde.label} (from {pdf_url})")
-
- created_dokumente.append(created_dokument)
- current_dokumente.append(created_dokument)
- gemeinde_result["dokument_ids"].append(created_dokument.id)
-
- except Exception as e:
- logger.error(f"Error processing PDF {pdf_url} for {gemeinde.label}: {str(e)}", exc_info=True)
- stats["errors"].append(f"{gemeinde.label}: Error processing PDF {pdf_url} - {str(e)}")
- continue
-
- # Update Gemeinde with all new dokumente
- if created_dokumente:
- updated_gemeinde = realEstateInterface.updateGemeinde(
- gemeinde.id,
- {"dokumente": current_dokumente}
- )
-
- if updated_gemeinde:
- logger.info(f"Successfully created {len(created_dokumente)} BZO document(s) for {gemeinde.label}")
- gemeinde_result["status"] = "created"
- stats["documents_created"] += len(created_dokumente)
- else:
- raise Exception("Failed to update Gemeinde")
- else:
- # No documents were successfully created
- gemeinde_result["status"] = "error"
- gemeinde_result["error"] = "No PDFs could be downloaded or processed"
- stats["errors"].append(f"{gemeinde.label}: No PDFs could be downloaded or processed")
-
- except Exception as e:
- logger.error(f"Error processing Gemeinde {gemeinde.label}: {str(e)}", exc_info=True)
- gemeinde_result["status"] = "error"
- gemeinde_result["error"] = str(e)
- stats["errors"].append(f"{gemeinde.label}: {str(e)}")
-
- results.append(gemeinde_result)
-
- logger.info(
- f"BZO document fetch completed: {stats['documents_created']} created, "
- f"{stats['documents_skipped']} skipped, {len(stats['errors'])} errors"
- )
-
- return {
- "success": True,
- "stats": stats,
- "results": results
- }
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error fetching BZO documents: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error fetching BZO documents: {str(e)}"
- )
diff --git a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py
index d4d7fae7..7d47c18f 100644
--- a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py
+++ b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py
@@ -13,7 +13,7 @@ import logging
from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelChat import ChatDocument
-from modules.datamodels.datamodelExtraction import DocumentIntent
+from modules.datamodels.datamodelExtraction import DocumentIntent, ContentExtracted
from modules.shared.workflowState import checkWorkflowStopped
logger = logging.getLogger(__name__)
diff --git a/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py b/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py
index 3adb613c..1945c550 100644
--- a/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py
+++ b/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py
@@ -12,10 +12,18 @@ Handles merging of JSON responses from multiple AI iterations, including:
"""
import json
import logging
-import re
from typing import Dict, Any, List, Optional, Tuple
-from modules.shared.jsonUtils import extractJsonString, repairBrokenJson, extractSectionsFromDocument
+from modules.shared.jsonUtils import (
+ extractJsonString,
+ repairBrokenJson,
+ extractSectionsFromDocument,
+ stripCodeFences,
+ normalizeJsonText,
+ closeJsonStructures,
+ tryParseJson,
+ extractFirstBalancedJson,
+)
from modules.datamodels.datamodelAi import JsonAccumulationState
logger = logging.getLogger(__name__)
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py
index 0b502e79..36d399d8 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py
@@ -3,7 +3,6 @@
import logging
import base64
import io
-import json
import re
from datetime import datetime, UTC
from typing import Dict, Any, Optional, List
@@ -200,7 +199,6 @@ class RendererPptx(BaseRenderer):
logger.warning(f"Could not clear placeholders: {str(placeholder_error)}")
# Add title as textbox
- from pptx.util import Inches
titleBox = slide.shapes.add_textbox(Inches(0.5), Inches(0.2), prs.slide_width - Inches(1), Inches(0.6))
titleFrame = titleBox.text_frame
titleFrame.text = slide_data.get("title", "Slide")
@@ -299,8 +297,6 @@ class RendererPptx(BaseRenderer):
# Convert to base64
pptx_bytes = buffer.getvalue()
- pptx_base64 = base64.b64encode(pptx_bytes).decode('utf-8')
-
logger.info(f"Successfully rendered PowerPoint presentation: {len(pptx_bytes)} bytes")
# Determine filename from document or title
@@ -1247,6 +1243,7 @@ class RendererPptx(BaseRenderer):
try:
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
+ from pptx.dml.color import RGBColor
if not images:
logger.debug("No images to render in frame")
diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
index dc88c7ab..799d1606 100644
--- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
+++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
@@ -18,7 +18,6 @@ from modules.nodeCatalog.portTypes import (
normalizeToSchema,
)
from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
-from modules.workflowAutomation.engine.executors.inputExecutor import PauseForHumanTaskError
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
build_presentation_envelope_from_plain_text,
diff --git a/modules/workflowAutomation/engine/executors/flowExecutor.py b/modules/workflowAutomation/engine/executors/flowExecutor.py
index 7a296204..0f5f85d1 100644
--- a/modules/workflowAutomation/engine/executors/flowExecutor.py
+++ b/modules/workflowAutomation/engine/executors/flowExecutor.py
@@ -21,7 +21,6 @@ class FlowExecutor:
) -> Any:
nodeType = node.get("type", "")
nodeOutputs = context.get("nodeOutputs", {})
- connectionMap = context.get("connectionMap", {})
nodeId = node.get("id", "")
inputSources = context.get("inputSources", {}).get(nodeId, {})
logger.info(
@@ -151,6 +150,8 @@ class FlowExecutor:
else:
nodes.append({"id": producer_id, "type": ""})
return {"nodes": nodes, "targetNodeId": node.get("id")}
+
+ def _compare_dates(self, left: Any, right: Any, op) -> bool:
"""Compare left/right as dates; op(a,b) is the comparison."""
def parse(v):
@@ -211,7 +212,6 @@ class FlowExecutor:
from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences
from modules.workflowAutomation.editor.switchOutput import (
build_switch_combined_output,
- build_switch_default_payload,
)
value = resolveParameterReferences(valueExpr, nodeOutputs)
From c1655bdd0aec88cbad7e64d2ea31320dd020a79d Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 8 Jun 2026 21:01:32 +0200
Subject: [PATCH 09/16] refactor: move billingWebhookHandler to serviceBilling
layer
Business logic for Stripe webhooks belongs in serviceCenter/services/serviceBilling/, not in routes/. Updates 3 lazy imports in routeBilling.py accordingly.
Co-authored-by: Cursor
---
modules/routes/routeBilling.py | 6 +++---
.../services/serviceBilling}/billingWebhookHandler.py | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
rename modules/{routes => serviceCenter/services/serviceBilling}/billingWebhookHandler.py (99%)
diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py
index 058038c0..d193f9bb 100644
--- a/modules/routes/routeBilling.py
+++ b/modules/routes/routeBilling.py
@@ -332,7 +332,7 @@ def _getStripeClient():
def _creditStripeSessionIfNeeded(billingInterface, session: Dict[str, Any], eventId: Optional[str] = None) -> CheckoutConfirmResponse:
"""Credit balance from Stripe Checkout session if not already credited."""
- from .billingWebhookHandler import creditStripeSessionIfNeeded
+ from modules.serviceCenter.services.serviceBilling.billingWebhookHandler import creditStripeSessionIfNeeded
return creditStripeSessionIfNeeded(billingInterface, session, eventId, CheckoutConfirmResponse)
@@ -1079,13 +1079,13 @@ async def stripeWebhook(
def handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
"""Handle checkout.session.completed for mode=subscription."""
- from .billingWebhookHandler import handleSubscriptionCheckoutCompleted as _handler
+ from modules.serviceCenter.services.serviceBilling.billingWebhookHandler import handleSubscriptionCheckoutCompleted as _handler
_handler(session, eventId, getRootInterface)
def _handleSubscriptionWebhook(event) -> None:
"""Process Stripe subscription webhook events."""
- from .billingWebhookHandler import handleSubscriptionWebhook as _handler
+ from modules.serviceCenter.services.serviceBilling.billingWebhookHandler import handleSubscriptionWebhook as _handler
_handler(event, getRootInterface)
diff --git a/modules/routes/billingWebhookHandler.py b/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py
similarity index 99%
rename from modules/routes/billingWebhookHandler.py
rename to modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py
index ecfe37b4..8e765cc7 100644
--- a/modules/routes/billingWebhookHandler.py
+++ b/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py
@@ -2,7 +2,7 @@
# All rights reserved.
"""
Stripe webhook and subscription business logic for billing.
-Extracted from routeBilling.py for maintainability.
+Handles checkout credit, subscription lifecycle transitions, and invoice events.
"""
import logging
From 4f8473bd701ed35bc2d4e1fafad0958c502ebb15 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 8 Jun 2026 23:35:31 +0200
Subject: [PATCH 10/16] cleaned servicebag and removed servicehub
---
app.py | 11 +
modules/datamodels/datamodelViews.py | 2 +-
.../datamodels/datamodelWorkflowAutomation.py | 2 +-
modules/demoConfigs/investorDemo2026.py | 27 +--
modules/demoConfigs/pwgDemo2026.py | 119 ++---------
.../neutralization/neutralizePlayground.py | 92 +++++----
.../mainServiceNeutralization.py | 12 +-
.../features/realEstate/serviceAiIntent.py | 7 +-
modules/features/realEstate/serviceBzo.py | 15 +-
.../interfaces/_legacyMigrationTelemetry.py | 4 +-
modules/interfaces/interfaceBootstrap.py | 13 +-
modules/interfaces/interfaceDbApp.py | 13 +-
modules/interfaces/interfaceDbManagement.py | 8 +-
modules/interfaces/interfaceFeatures.py | 7 +-
.../interfaces/interfaceWorkflowAutomation.py | 10 +-
.../editor => nodeCatalog}/entryPoints.py | 70 ++++---
modules/routes/routeBilling.py | 4 +-
modules/routes/routeClickup.py | 10 +-
modules/routes/routeSharepoint.py | 16 +-
modules/routes/routeSystem.py | 4 +-
modules/routes/routeWorkflowAutomation.py | 37 ++--
modules/serviceCenter/__init__.py | 47 +++--
modules/serviceCenter/resolver.py | 7 +-
modules/serviceCenter/serviceHub.py | 189 ------------------
.../serviceAgent/actionToolAdapter.py | 4 +-
.../coreTools/_connectionTools.py | 5 +-
.../coreTools/_crossWorkflowTools.py | 8 +-
.../coreTools/_dataSourceTools.py | 4 +-
.../coreTools/_featureSubAgentTools.py | 5 -
.../serviceAgent/coreTools/_helpers.py | 13 +-
.../serviceAgent/coreTools/_mediaTools.py | 42 ++--
.../serviceAgent/coreTools/_workspaceTools.py | 50 ++---
.../services/serviceAgent/mainServiceAgent.py | 29 +--
.../serviceAi/subContentExtraction.py | 4 +-
.../services/serviceAi/subDocumentIntents.py | 2 +-
.../services/serviceBilling/stripeCheckout.py | 2 +-
.../services/serviceChat/mainServiceChat.py | 145 ++++++++++++++
.../services/serviceClickup/__init__.py | 4 +-
.../serviceClickup/mainServiceClickup.py | 2 +-
.../mainServiceGeneration.py | 24 +--
modules/shared/systemComponentRegistry.py | 32 +++
.../workflowArtifactVisibility.py | 4 +-
modules/shared/workflowState.py | 2 +-
.../engine/executionEngine.py | 74 +++----
.../engine/executors/actionNodeExecutor.py | 40 +---
.../engine/executors/inputExecutor.py | 4 +-
.../engine/runFileLogger.py | 134 ++++++-------
modules/workflowAutomation/helpers.py | 4 +-
.../mainWorkflowAutomation.py | 64 +-----
.../scheduler/mainScheduler.py | 4 +-
.../methods/methodAi/actions/process.py | 21 +-
.../methods/methodAi/actions/webResearch.py | 21 +-
modules/workflows/methods/methodBase.py | 6 +-
.../methodContext/actions/extractContent.py | 28 +--
.../methods/methodFile/actions/create.py | 45 +----
.../actions/downloadFileByPath.py | 18 +-
.../processing/modes/modeAutomation.py | 4 +-
.../workflows/processing/modes/modeBase.py | 7 +-
.../workflows/processing/workflowProcessor.py | 16 +-
modules/workflows/workflowManager.py | 18 +-
tests/eval/runTrusteeBenchmark.py | 27 ++-
tests/functional/test01_ai_model_selection.py | 23 ++-
tests/functional/test02_ai_models.py | 27 ++-
tests/functional/test03_ai_operations.py | 28 ++-
tests/functional/test04_ai_behavior.py | 28 ++-
.../workflow/test_extract_content_handover.py | 14 +-
66 files changed, 814 insertions(+), 948 deletions(-)
rename modules/{workflowAutomation/editor => nodeCatalog}/entryPoints.py (63%)
delete mode 100644 modules/serviceCenter/serviceHub.py
create mode 100644 modules/shared/systemComponentRegistry.py
rename modules/{workflowAutomation/engine => shared}/workflowArtifactVisibility.py (90%)
diff --git a/app.py b/app.py
index 185ea95d..f76f7083 100644
--- a/app.py
+++ b/app.py
@@ -311,6 +311,17 @@ async def lifespan(app: FastAPI):
# AI connectors already pre-warmed at module-load via _eager_prewarm() in aicoreModelRegistry.
+ # Register system-component lifecycle hooks (Composition Root — inverts L4->L5b dependency)
+ from modules.shared.systemComponentRegistry import registerLifecycleHook
+ from modules.workflowAutomation.mainWorkflowAutomation import (
+ onBootstrap as _waOnBootstrap,
+ onMandateDelete as _waOnMandateDelete,
+ onInstanceCreate as _waOnInstanceCreate,
+ )
+ registerLifecycleHook("onBootstrap", _waOnBootstrap)
+ registerLifecycleHook("onMandateDelete", _waOnMandateDelete)
+ registerLifecycleHook("onInstanceCreate", _waOnInstanceCreate)
+
# Bootstrap database if needed (creates initial users, mandates, roles, etc.)
# This must happen before getting root interface
from modules.security.rootAccess import getRootDbAppConnector
diff --git a/modules/datamodels/datamodelViews.py b/modules/datamodels/datamodelViews.py
index 03a5a27f..28625d16 100644
--- a/modules/datamodels/datamodelViews.py
+++ b/modules/datamodels/datamodelViews.py
@@ -247,7 +247,7 @@ from modules.datamodels.datamodelFeatures import AutoWorkflow
@i18nModel("Workflow (Ansicht)")
-class Automation2WorkflowView(AutoWorkflow):
+class AutoWorkflowView(AutoWorkflow):
"""AutoWorkflow extended with computed dashboard fields.
Used exclusively for /api/attributes/ so the frontend can resolve column
diff --git a/modules/datamodels/datamodelWorkflowAutomation.py b/modules/datamodels/datamodelWorkflowAutomation.py
index c9957c25..51d84814 100644
--- a/modules/datamodels/datamodelWorkflowAutomation.py
+++ b/modules/datamodels/datamodelWorkflowAutomation.py
@@ -53,7 +53,7 @@ class AutoTemplateScope(str, Enum):
SYSTEM = "system"
-GRAPHICAL_EDITOR_DATABASE = "poweron_graphicaleditor"
+WORKFLOW_AUTOMATION_DATABASE = "poweron_graphicaleditor"
# ---------------------------------------------------------------------------
diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py
index 62b523d1..84fc5e01 100644
--- a/modules/demoConfigs/investorDemo2026.py
+++ b/modules/demoConfigs/investorDemo2026.py
@@ -60,7 +60,7 @@ class InvestorDemo2026(BaseDemoConfig):
label = "Investor Demo April 2026"
description = (
"Two mandates (HappyLife AG + Alpina Treuhand AG), one SysAdmin user, "
- "trustee with RMA, workspace, graph editor, and neutralization."
+ "trustee with RMA, workspace, workflow automation, and neutralization."
)
credentials = [
{
@@ -554,20 +554,21 @@ class InvestorDemo2026(BaseDemoConfig):
try:
from modules.datamodels.datamodelWorkflowAutomation import (
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
+ WORKFLOW_AUTOMATION_DATABASE,
)
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
- geDb = DatabaseConnector(
+ waDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
- dbDatabase="poweron_graphicaleditor",
+ dbDatabase=WORKFLOW_AUTOMATION_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,
)
- workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
+ workflows = waDb.getRecordset(AutoWorkflow, recordFilter={
"mandateId": mandateId,
"featureInstanceId": featureInstanceId,
}) or []
@@ -577,20 +578,20 @@ class InvestorDemo2026(BaseDemoConfig):
if not wfId:
continue
- for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
- geDb.recordDelete(AutoVersion, version.get("id"))
+ for version in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
+ waDb.recordDelete(AutoVersion, version.get("id"))
- runs = geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []
+ runs = waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []
for run in runs:
runId = run.get("id")
- for stepLog in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
- geDb.recordDelete(AutoStepLog, stepLog.get("id"))
- geDb.recordDelete(AutoRun, runId)
+ for stepLog in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
+ waDb.recordDelete(AutoStepLog, stepLog.get("id"))
+ waDb.recordDelete(AutoRun, runId)
- for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
- geDb.recordDelete(AutoTask, task.get("id"))
+ for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
+ waDb.recordDelete(AutoTask, task.get("id"))
- geDb.recordDelete(AutoWorkflow, wfId)
+ waDb.recordDelete(AutoWorkflow, wfId)
if workflows:
summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}")
diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py
index efcd8c8a..2d301f7a 100644
--- a/modules/demoConfigs/pwgDemo2026.py
+++ b/modules/demoConfigs/pwgDemo2026.py
@@ -51,9 +51,6 @@ _FEATURES_PWG = [
{"code": "neutralization", "label": "Datenschutz"},
]
-# Filename markers used to identify the imported pilot workflow on remove().
-_PILOT_WORKFLOW_LABEL = "PWG Pilot: Jahresmietzinsbestätigung"
-_PILOT_WORKFLOW_FILE = "pwg-mietzinsbestaetigung-pilot.workflow.json"
_SEED_TRUSTEE_FILE = "_seedTrusteeData.json"
@@ -62,8 +59,7 @@ class PwgDemo2026(BaseDemoConfig):
label = "PWG Pilot Demo (Mietzinsbestätigungen)"
description = (
"Stiftung PWG, ein Demo-Sachbearbeiter, Trustee mit fiktiven Mietern, "
- "Graph-Editor mit dem Pilot-Workflow für Jahresmietzinsbestätigungen "
- "(als File importiert, active=false). Idempotent."
+ "Workflow-Automation (als File importiert, active=false). Idempotent."
)
credentials = [
{
@@ -536,92 +532,6 @@ class PwgDemo2026(BaseDemoConfig):
if skippedTenants:
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 WorkflowAutomation DB.
-
- Uses the schema-aware import pipeline introduced in Phase 1
- (``_workflowFileSchema.envelopeToWorkflowData`` +
- ``WorkflowAutomationObjects.importWorkflowFromDict``). The workflow is
- always created with ``active=False`` so a manual trigger is required
- — this matches the demo-bootstrap safety default.
- """
- envelopePath = _demoDataDir() / "workflows" / _PILOT_WORKFLOW_FILE
- if not envelopePath.is_file():
- summary["errors"].append(f"Pilot workflow file missing: {envelopePath}")
- return
- try:
- envelope = json.loads(envelopePath.read_text(encoding="utf-8"))
- except Exception as exc:
- summary["errors"].append(f"Pilot workflow file unreadable: {exc}")
- return
-
- try:
- geDb = _openWorkflowAutomationDb()
- except Exception as exc:
- summary["errors"].append(f"WorkflowAutomation DB connection failed: {exc}")
- return
-
- from modules.nodeCatalog._workflowFileSchema import (
- envelopeToWorkflowData,
- validateFileEnvelope,
- )
- from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow
- from modules.workflowAutomation.editor.nodeRegistry import STATIC_NODE_TYPES
-
- existing = geDb.getRecordset(AutoWorkflow, recordFilter={
- "mandateId": mandateId,
- "featureInstanceId": featureInstanceId,
- "label": _PILOT_WORKFLOW_LABEL,
- }) or []
- if existing:
- summary["skipped"].append(f"Pilot workflow already imported ({existing[0].get('id')})")
- return
-
- knownTypes = [n.get("id") for n in STATIC_NODE_TYPES if isinstance(n, dict) and n.get("id")]
- try:
- normalized, warnings = validateFileEnvelope(envelope, knownNodeTypes=knownTypes)
- except Exception as exc:
- summary["errors"].append(f"Pilot workflow envelope invalid: {exc}")
- return
- if warnings:
- summary["created"].append(f"Pilot workflow warnings: {warnings}")
-
- data = envelopeToWorkflowData(
- normalized,
- mandateId=mandateId,
- featureInstanceId=featureInstanceId,
- )
- # Inject the trustee feature-instance id into the parameters so the
- # node runtime resolves it without manual editor cleanup.
- trusteeInstanceId = self._guessTrusteeInstanceId(mandateId)
- if trusteeInstanceId:
- for node in data.get("graph", {}).get("nodes", []) or []:
- params = node.get("parameters") or {}
- if "featureInstanceId" in params and not params["featureInstanceId"]:
- params["featureInstanceId"] = trusteeInstanceId
- node["parameters"] = params
-
- # Force-import: AutoWorkflow.create accepts our envelope-derived data
- # (graph, label, invocations, …) verbatim; we add ids/timestamps that
- # AutoWorkflow expects.
- record = AutoWorkflow(
- id=str(uuid.uuid4()),
- mandateId=mandateId,
- featureInstanceId=featureInstanceId,
- label=data.get("label") or _PILOT_WORKFLOW_LABEL,
- description=data.get("description") or "",
- tags=data.get("tags") or [],
- graph=data.get("graph") or {"nodes": [], "connections": []},
- invocations=data.get("invocations") or [],
- templateScope=data.get("templateScope") or "instance",
- sharedReadOnly=bool(data.get("sharedReadOnly")),
- notifyOnFailure=bool(data.get("notifyOnFailure", True)),
- active=False,
- )
- 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 workflowAutomation instance {featureInstanceId}")
-
def _guessTrusteeInstanceId(self, mandateId: str) -> Optional[str]:
"""Return the first trustee feature-instance id of the given mandate.
@@ -728,23 +638,23 @@ class PwgDemo2026(BaseDemoConfig):
AutoVersion,
AutoWorkflow,
)
- geDb = _openWorkflowAutomationDb()
- workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
+ waDb = _openWorkflowAutomationDb()
+ workflows = waDb.getRecordset(AutoWorkflow, recordFilter={
"mandateId": mandateId,
"featureInstanceId": featureInstanceId,
}) or []
for wf in workflows:
wfId = wf.get("id")
- for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
- geDb.recordDelete(AutoVersion, version.get("id"))
- for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
+ for version in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
+ waDb.recordDelete(AutoVersion, version.get("id"))
+ for run in waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
runId = run.get("id")
- for step in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
- geDb.recordDelete(AutoStepLog, step.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)
+ for step in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
+ waDb.recordDelete(AutoStepLog, step.get("id"))
+ waDb.recordDelete(AutoRun, runId)
+ for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
+ waDb.recordDelete(AutoTask, task.get("id"))
+ waDb.recordDelete(AutoWorkflow, wfId)
if workflows:
summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}")
except Exception as e:
@@ -814,12 +724,13 @@ def _openTrusteeDb():
def _openWorkflowAutomationDb():
- """Open a privileged DB connection to ``poweron_graphicaleditor``."""
+ """Open a privileged DB connection to the workflow-automation database."""
from modules.connectors.connectorDbPostgre import DatabaseConnector
+ from modules.datamodels.datamodelWorkflowAutomation import WORKFLOW_AUTOMATION_DATABASE
from modules.shared.configuration import APP_CONFIG
return DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
- dbDatabase="poweron_graphicaleditor",
+ dbDatabase=WORKFLOW_AUTOMATION_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)),
diff --git a/modules/features/neutralization/neutralizePlayground.py b/modules/features/neutralization/neutralizePlayground.py
index 0bd50b49..e855ad22 100644
--- a/modules/features/neutralization/neutralizePlayground.py
+++ b/modules/features/neutralization/neutralizePlayground.py
@@ -9,7 +9,8 @@ from urllib.parse import urlparse, unquote
from modules.datamodels.datamodelUam import User
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig, DataNeutralizationSnapshot
from .interfaceFeatureNeutralizer import getInterface as _getNeutralizerInterface
-from modules.serviceCenter.serviceHub import getInterface as getServices
+from modules.serviceCenter import getService
+from modules.serviceCenter.context import ServiceCenterContext
logger = logging.getLogger(__name__)
@@ -21,10 +22,13 @@ class NeutralizationPlayground:
self.currentUser = currentUser
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
- self.services = getServices(currentUser, None, mandateId=mandateId, featureInstanceId=featureInstanceId)
+ self._ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=featureInstanceId)
+
+ def _getService(self, name: str):
+ return getService(name, self._ctx)
def processText(self, text: str) -> Dict[str, Any]:
- return self.services.neutralization.processText(text)
+ return self._getService("neutralization").processText(text)
async def processUploadedFileAsync(self, file_bytes: bytes, filename: str) -> Dict[str, Any]:
"""Process an uploaded file (bytes + filename). Returns neutralized result for text or binary.
@@ -43,32 +47,35 @@ class NeutralizationPlayground:
original_file_id = None
neutralized_file_id = None
+ neutralizationService = self._getService("neutralization")
- # Save original file to user files
- if self.services.interfaceDbComponent:
+ try:
+ chatService = self._getService("chat")
+ except Exception:
+ chatService = None
+
+ if chatService:
try:
- file_item, _ = self.services.interfaceDbComponent.saveUploadedFile(file_bytes, filename)
+ file_item, _ = chatService.saveUploadedFile(file_bytes, filename)
original_file_id = str(file_item.id)
except Exception as e:
logger.warning(f"Could not save original file to user files: {e}")
if is_binary:
- result = await self.services.neutralization.processBinaryBytesAsync(file_bytes, filename, mime)
+ result = await neutralizationService.processBinaryBytesAsync(file_bytes, filename, mime)
neu_bytes = result.get('neutralized_bytes')
logger.debug(f"Binary result: neu_bytes type={type(neu_bytes).__name__}, len={len(neu_bytes) if neu_bytes is not None else 0}")
if neu_bytes is not None and len(neu_bytes) > 0:
result['neutralized_file_base64'] = base64.b64encode(neu_bytes).decode('ascii')
result['neutralized_file_name'] = result.get('neutralized_file_name', f'neutralized_{filename}')
result['mime_type'] = result.get('mime_type', mime)
- # Save neutralized binary to user files
- if self.services.interfaceDbComponent:
+ if chatService:
try:
neu_name = result['neutralized_file_name']
- file_item, _ = self.services.interfaceDbComponent.saveUploadedFile(neu_bytes, neu_name)
+ file_item, _ = chatService.saveUploadedFile(neu_bytes, neu_name)
neutralized_file_id = str(file_item.id)
except Exception as e:
logger.warning(f"Could not save neutralized file to user files: {e}")
- # Remove raw bytes before JSON response (avoid serialization issues; use base64 only)
result.pop('neutralized_bytes', None)
result['original_file_id'] = original_file_id
result['neutralized_file_id'] = neutralized_file_id
@@ -86,15 +93,14 @@ class NeutralizationPlayground:
'neutralized_file_id': None,
'processed_info': {'type': 'error', 'error': 'File could not be decoded as text. Supported: UTF-8, Latin-1. For PDF/Word/Excel, use supported binary formats.'}
}
- result = await self.services.neutralization.processTextAsync(text_content)
+ result = await neutralizationService.processTextAsync(text_content)
result['neutralized_file_name'] = f'neutralized_{filename}'
- # Save neutralized text as file to user files
- if self.services.interfaceDbComponent and result.get('neutralized_text') is not None:
+ if chatService and result.get('neutralized_text') is not None:
try:
neu_text = result['neutralized_text']
neu_bytes = neu_text.encode('utf-8')
neu_name = result['neutralized_file_name']
- file_item, _ = self.services.interfaceDbComponent.saveUploadedFile(neu_bytes, neu_name)
+ file_item, _ = chatService.saveUploadedFile(neu_bytes, neu_name)
neutralized_file_id = str(file_item.id)
except Exception as e:
logger.warning(f"Could not save neutralized text file to user files: {e}")
@@ -111,7 +117,7 @@ class NeutralizationPlayground:
errors: List[str] = []
for fileId in fileIds:
try:
- res = self.services.neutralization.processFile(fileId)
+ res = self._getService("neutralization").processFile(fileId)
results.append({
'file_id': fileId,
'neutralized_file_name': res.get('neutralized_file_name'),
@@ -137,12 +143,12 @@ class NeutralizationPlayground:
# Cleanup attributes
def cleanAttributes(self, fileId: str) -> bool:
- return self.services.neutralization.deleteNeutralizationAttributes(fileId)
+ return self._getService("neutralization").deleteNeutralizationAttributes(fileId)
# Stats
def getStats(self) -> Dict[str, Any]:
try:
- allAttributes = self.services.neutralization.getAttributes()
+ allAttributes = self._getService("neutralization").getAttributes()
patternCounts: Dict[str, int] = {}
for attr in allAttributes:
# Handle both dict and object access patterns
@@ -184,24 +190,24 @@ class NeutralizationPlayground:
# Additional methods needed by the route
def getConfig(self) -> Optional[DataNeutraliserConfig]:
"""Get neutralization configuration"""
- return self.services.neutralization.getConfig()
+ return self._getService("neutralization").getConfig()
def saveConfig(self, configData: Dict[str, Any]) -> DataNeutraliserConfig:
"""Save neutralization configuration"""
- return self.services.neutralization.saveConfig(configData)
+ return self._getService("neutralization").saveConfig(configData)
def neutralizeText(self, text: str, fileId: str = None) -> Dict[str, Any]:
"""Neutralize text content"""
- return self.services.neutralization.processText(text)
+ return self._getService("neutralization").processText(text)
def resolveText(self, text: str) -> str:
"""Resolve UIDs in neutralized text back to original text"""
- return self.services.neutralization.resolveText(text)
+ return self._getService("neutralization").resolveText(text)
def getSnapshots(self) -> List[DataNeutralizationSnapshot]:
"""Return stored neutralization text snapshots."""
try:
- return self.services.neutralization.getSnapshots()
+ return self._getService("neutralization").getSnapshots()
except Exception as e:
logger.error(f"Error getting snapshots: {e}")
return []
@@ -209,7 +215,7 @@ class NeutralizationPlayground:
def getAttributes(self, fileId: str = None) -> List[DataNeutralizerAttributes]:
"""Get neutralization attributes, optionally filtered by file ID"""
try:
- allAttributes = self.services.neutralization.getAttributes()
+ allAttributes = self._getService("neutralization").getAttributes()
if fileId:
want = str(fileId).strip()
@@ -227,8 +233,7 @@ class NeutralizationPlayground:
async def processSharepointFiles(self, sourcePath: str, targetPath: str) -> Dict[str, Any]:
"""Process files from SharePoint source path and store neutralized files in target path"""
- from modules.serviceCenter.services.serviceSharepoint.mainServiceSharepoint import SharepointService
- processor = SharepointProcessor(self.currentUser, self.services)
+ processor = SharepointProcessor(self.currentUser, self._ctx)
return await processor.processSharepointFiles(sourcePath, targetPath)
def batchNeutralizeFiles(self, filesData: List[Dict[str, Any]]) -> Dict[str, Any]:
@@ -247,15 +252,18 @@ class NeutralizationPlayground:
# Internal SharePoint helper module separated to keep feature logic tidy
class SharepointProcessor:
- def __init__(self, currentUser: User, services):
+ def __init__(self, currentUser: User, ctx: ServiceCenterContext):
self.currentUser = currentUser
- self.services = services
+ self._ctx = ctx
+ self._sharepoint = getService("sharepoint", ctx)
+ self._neutralization = getService("neutralization", ctx)
+ from modules.interfaces.interfaceDbApp import getInterface as _getAppInterface
+ self._interfaceDbApp = _getAppInterface(currentUser, mandateId=ctx.mandate_id)
async def processSharepointFiles(self, sourcePath: str, targetPath: str) -> Dict[str, Any]:
try:
logger.info(f"Processing SharePoint files from {sourcePath} to {targetPath}")
- # Get SharePoint connection
connection = await self._getSharepointConnection(sourcePath)
if not connection:
return {
@@ -265,8 +273,7 @@ class SharepointProcessor:
'errors': ['No SharePoint connection found'],
}
- # Set access token for SharePoint service
- if not self.services.sharepoint.setAccessTokenFromConnection(connection):
+ if not self._sharepoint.setAccessTokenFromConnection(connection):
return {
'success': False,
'message': 'Failed to set SharePoint access token',
@@ -286,8 +293,7 @@ class SharepointProcessor:
async def _getSharepointConnection(self, sharepointPath: str = None):
try:
- # Use interface method to get user connections
- connections = self.services.interfaceDbApp.getUserConnections(self.services.interfaceDbApp.userId)
+ connections = self._interfaceDbApp.getUserConnections(self._interfaceDbApp.userId)
def _is_msft_connection(c):
av = c.authority.value if hasattr(c.authority, 'value') else str(getattr(c, 'authority', ''))
return av and str(av).lower() == 'msft'
@@ -322,7 +328,7 @@ class SharepointProcessor:
for connection in connections:
try:
- if not self.services.sharepoint.setAccessTokenFromConnection(connection):
+ if not self._sharepoint.setAccessTokenFromConnection(connection):
continue
if await self._testSharepointAccess(sharepointPath):
logger.info(f"Found matching connection for domain {targetDomain}: {connection.get('id')}")
@@ -340,7 +346,7 @@ class SharepointProcessor:
siteUrl, _ = self._parseSharepointPath(sharepointPath)
if not siteUrl:
return False
- siteInfo = await self.services.sharepoint.findSiteByWebUrl(siteUrl)
+ siteInfo = await self._sharepoint.findSiteByWebUrl(siteUrl)
return siteInfo is not None
except Exception:
return False
@@ -351,17 +357,17 @@ class SharepointProcessor:
targetSite, targetFolder = self._parseSharepointPath(targetPath)
if not sourceSite or not targetSite:
return {'success': False, 'message': 'Invalid SharePoint path format', 'processed_files': 0, 'errors': ['Invalid SharePoint path format']}
- sourceSiteInfo = await self.services.sharepoint.findSiteByWebUrl(sourceSite)
+ sourceSiteInfo = await self._sharepoint.findSiteByWebUrl(sourceSite)
if not sourceSiteInfo:
return {'success': False, 'message': f'Source site not found: {sourceSite}', 'processed_files': 0, 'errors': [f'Source site not found: {sourceSite}']}
- targetSiteInfo = await self.services.sharepoint.findSiteByWebUrl(targetSite)
+ targetSiteInfo = await self._sharepoint.findSiteByWebUrl(targetSite)
if not targetSiteInfo:
return {'success': False, 'message': f'Target site not found: {targetSite}', 'processed_files': 0, 'errors': [f'Target site not found: {targetSite}']}
logger.info(f"Listing files in folder: {sourceFolder} for site: {sourceSiteInfo['id']}")
- files = await self.services.sharepoint.listFolderContents(sourceSiteInfo['id'], sourceFolder)
+ files = await self._sharepoint.listFolderContents(sourceSiteInfo['id'], sourceFolder)
if not files:
logger.warning(f"No files found in folder '{sourceFolder}', trying root folder")
- files = await self.services.sharepoint.listFolderContents(sourceSiteInfo['id'], '')
+ files = await self._sharepoint.listFolderContents(sourceSiteInfo['id'], '')
if files:
folders = [f for f in files if f.get('type') == 'folder']
folderNames = [f.get('name') for f in folders]
@@ -385,7 +391,7 @@ class SharepointProcessor:
async def _processSingle(fileInfo: Dict[str, Any]):
try:
- fileContent = await self.services.sharepoint.downloadFile(sourceSiteInfo['id'], fileInfo['id'])
+ fileContent = await self._sharepoint.downloadFile(sourceSiteInfo['id'], fileInfo['id'])
if not fileContent:
return {'error': f"Failed to download file: {fileInfo['name']}"}
name_lower = (fileInfo.get('name') or '').lower()
@@ -402,7 +408,7 @@ class SharepointProcessor:
mime = next((mime_map[ext] for ext in BINARY_EXTS if name_lower.endswith(ext)), 'text/plain')
if is_binary:
- result = self.services.neutralization.processBinaryBytes(fileContent, fileInfo['name'], mime)
+ result = self._neutralization.processBinaryBytes(fileContent, fileInfo['name'], mime)
if result.get('neutralized_bytes'):
content_to_upload = result['neutralized_bytes']
else:
@@ -412,11 +418,11 @@ class SharepointProcessor:
textContent = fileContent.decode('utf-8')
except UnicodeDecodeError:
textContent = fileContent.decode('latin-1')
- result = await self.services.neutralization.processTextAsync(textContent)
+ result = await self._neutralization.processTextAsync(textContent)
content_to_upload = (result.get('neutralized_text') or '').encode('utf-8')
neutralizedFilename = f"neutralized_{fileInfo['name']}"
- uploadResult = await self.services.sharepoint.uploadFile(targetSiteInfo['id'], targetFolder, neutralizedFilename, content_to_upload)
+ uploadResult = await self._sharepoint.uploadFile(targetSiteInfo['id'], targetFolder, neutralizedFilename, content_to_upload)
if 'error' in uploadResult:
return {'error': f"Failed to upload neutralized file: {neutralizedFilename} - {uploadResult['error']}"}
return {
diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
index 809d6be5..4cfec864 100644
--- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
+++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
@@ -51,7 +51,6 @@ class NeutralizationService:
"""
self.services = serviceCenter
self._getService = getServiceFn
- self.interfaceDbComponent = getattr(serviceCenter, "interfaceDbComponent", None)
# Create feature-specific interface for neutralizer DB operations
self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None
@@ -305,19 +304,20 @@ class NeutralizationService:
raise
def processFile(self, fileId: str) -> Dict[str, Any]:
- """Neutralize a file referenced by its fileId using component interface.
+ """Neutralize a file referenced by its fileId using ChatService.
Supports text files directly; PDF/DOCX/XLSX/PPTX via extract -> neutralize -> generate."""
- if not self.interfaceDbComponent:
- raise ValueError("Component interface is required to process a file by fileId")
+ chatService = self._getService("chat") if self._getService else None
+ if not chatService:
+ raise ValueError("Chat service is required to process a file by fileId")
fileInfo = None
try:
- fileInfo = self.interfaceDbComponent.getFile(fileId)
+ fileInfo = chatService.getFile(fileId)
except Exception:
fileInfo = None
fileName = getattr(fileInfo, 'fileName', None) if fileInfo else None
mimeType = getattr(fileInfo, 'mimeType', None) if fileInfo else None
- fileData = self.interfaceDbComponent.getFileData(fileId)
+ fileData = chatService.getFileData(fileId)
if not fileData:
raise ValueError(f"No file data found for fileId: {fileId}")
diff --git a/modules/features/realEstate/serviceAiIntent.py b/modules/features/realEstate/serviceAiIntent.py
index 62efb1a0..ca53c98e 100644
--- a/modules/features/realEstate/serviceAiIntent.py
+++ b/modules/features/realEstate/serviceAiIntent.py
@@ -24,7 +24,8 @@ from .datamodelFeatureRealEstate import (
Kanton,
Land,
)
-from modules.serviceCenter.serviceHub import getInterface as getServices
+from modules.serviceCenter import getService
+from modules.serviceCenter.context import ServiceCenterContext
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
from .serviceGeometry import fetch_parcel_polygon_from_swisstopo
@@ -231,8 +232,8 @@ async def processNaturalLanguageCommand(
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})")
logger.debug(f"User input: {userInput}")
- services = getServices(currentUser, workflow=None, mandateId=mandateId)
- aiService = services.ai
+ ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId)
+ aiService = getService("ai", ctx)
intentAnalysis = await analyzeUserIntent(aiService, userInput)
diff --git a/modules/features/realEstate/serviceBzo.py b/modules/features/realEstate/serviceBzo.py
index c7510fb3..178c8021 100644
--- a/modules/features/realEstate/serviceBzo.py
+++ b/modules/features/realEstate/serviceBzo.py
@@ -12,7 +12,8 @@ from fastapi import HTTPException, status
from modules.datamodels.datamodelUam import User
from .datamodelFeatureRealEstate import DokumentTyp
-from modules.serviceCenter.serviceHub import getInterface as getServices
+from modules.serviceCenter import getService
+from modules.serviceCenter.context import ServiceCenterContext
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
from modules.features.realEstate.bzoDocumentRetriever import BZODocumentRetriever
@@ -233,10 +234,8 @@ async def extract_bzo_information(
bzo_params_result = None
try:
- services = getServices(
- currentUser, workflow=None, mandateId=_mandateId, featureInstanceId=featureInstanceId
- )
- ai_service = services.ai
+ ctx = ServiceCenterContext(user=currentUser, mandate_id=_mandateId, feature_instance_id=featureInstanceId)
+ ai_service = getService("ai", ctx)
bzo_params_result = await run_bzo_params_extraction(
extracted_content=all_extracted_content,
bauzone=bauzone,
@@ -521,10 +520,8 @@ async def generate_bauzone_ai_summary(
AI-generated summary string
"""
try:
- services = getServices(
- currentUser, workflow=None, mandateId=mandateId, featureInstanceId=featureInstanceId
- )
- aiService = services.ai
+ ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=featureInstanceId)
+ aiService = getService("ai", ctx)
context_parts = []
diff --git a/modules/interfaces/_legacyMigrationTelemetry.py b/modules/interfaces/_legacyMigrationTelemetry.py
index 02c2c184..d80905b1 100644
--- a/modules/interfaces/_legacyMigrationTelemetry.py
+++ b/modules/interfaces/_legacyMigrationTelemetry.py
@@ -158,7 +158,7 @@ def _backfillTargetFeatureInstanceId() -> None:
"""
def _do() -> None:
from modules.shared.configuration import APP_CONFIG
- from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow
+ from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow, WORKFLOW_AUTOMATION_DATABASE
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
dbUser = APP_CONFIG.get("DB_USER")
@@ -166,7 +166,7 @@ def _backfillTargetFeatureInstanceId() -> None:
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
geDb = DatabaseConnector(
dbHost=dbHost,
- dbDatabase="poweron_graphicaleditor",
+ dbDatabase=WORKFLOW_AUTOMATION_DATABASE,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 002cb02d..4764cd4a 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -110,12 +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}")
+ # System-component lifecycle hooks (registered via app.py Composition Root)
+ from modules.shared.systemComponentRegistry import getLifecycleHooks
+ for _scHook in getLifecycleHooks("onBootstrap"):
+ try:
+ _scHook()
+ except Exception as _scErr:
+ logger.warning(f"onBootstrap hook for system component failed: {_scErr}")
# Let features run their own bootstrap logic via lifecycle hooks
from modules.shared.featureDiscovery import loadFeatureMainModules
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 023e07f3..d5fb2e49 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1870,12 +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-sc. System-component cascade-delete (registered via app.py Composition Root)
+ from modules.shared.systemComponentRegistry import getLifecycleHooks
+ for _scHook in getLifecycleHooks("onMandateDelete"):
+ try:
+ _scHook(mandateId, instances)
+ except Exception as _scErr:
+ logger.warning(f"onMandateDelete hook for system component failed: {_scErr}")
# 0-pre. Let features cascade-delete their own data via lifecycle hooks
from modules.shared.featureDiscovery import loadFeatureMainModules
diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py
index 35a4008e..b3acfcfc 100644
--- a/modules/interfaces/interfaceDbManagement.py
+++ b/modules/interfaces/interfaceDbManagement.py
@@ -807,7 +807,7 @@ class ComponentObjects:
next ``updateFile`` / ``getFile`` then rejects with
``File with ID ... not found`` -- the well-known "ghost duplicate"
symptom seen when ``interfaceDbComponent`` is initialised without an
- ``featureInstanceId`` (e.g. via ``serviceHub``) but a same-hash+name
+ ``featureInstanceId`` (e.g. via ``serviceCenter``) but a same-hash+name
file exists in another featureInstance under the same mandate.
We therefore cross-check the candidate through the RBAC-aware ``getFile``
before returning it; if RBAC blocks it, we treat it as "no duplicate
@@ -933,9 +933,7 @@ class ComponentObjects:
If pagination is provided: PaginatedResult with items and metadata
"""
def _convertFileItems(files):
- from modules.workflowAutomation.engine.workflowArtifactVisibility import (
- suppress_workflow_file_in_workspace_ui,
- )
+ from modules.shared.workflowArtifactVisibility import suppressWorkflowFileInWorkspaceUi
fileItems = []
for file in files:
@@ -949,7 +947,7 @@ class ComponentObjects:
fileName = file.get("fileName")
if not fileName or fileName == "None":
continue
- if suppress_workflow_file_in_workspace_ui(file):
+ if suppressWorkflowFileInWorkspaceUi(file):
continue
if file.get("scope") is None:
diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py
index c947806c..c391deaa 100644
--- a/modules/interfaces/interfaceFeatures.py
+++ b/modules/interfaces/interfaceFeatures.py
@@ -321,7 +321,12 @@ class FeatureInterface:
f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})"
)
- from modules.workflowAutomation.mainWorkflowAutomation import onInstanceCreate as _waOnInstanceCreate
+ from modules.shared.systemComponentRegistry import getLifecycleHooks
+ _onInstanceCreateHooks = getLifecycleHooks("onInstanceCreate")
+ if not _onInstanceCreateHooks:
+ logger.warning("_copyTemplateWorkflows: no onInstanceCreate hooks registered")
+ return 0
+ _waOnInstanceCreate = _onInstanceCreateHooks[0]
try:
copied = _waOnInstanceCreate(mandateId, instanceId, featureCode, templateWorkflows)
diff --git a/modules/interfaces/interfaceWorkflowAutomation.py b/modules/interfaces/interfaceWorkflowAutomation.py
index ba8fe6e7..9859ff2d 100644
--- a/modules/interfaces/interfaceWorkflowAutomation.py
+++ b/modules/interfaces/interfaceWorkflowAutomation.py
@@ -46,7 +46,7 @@ def _make_json_serializable(obj: Any, _depth: int = 0) -> Any:
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelWorkflowAutomation import (
- GRAPHICAL_EDITOR_DATABASE,
+ WORKFLOW_AUTOMATION_DATABASE,
AutoWorkflow,
AutoVersion,
AutoRun,
@@ -59,15 +59,15 @@ from modules.dbHelpers.dbRegistry import registerDatabase
logger = logging.getLogger(__name__)
-workflowAutomationDatabase = GRAPHICAL_EDITOR_DATABASE
+workflowAutomationDatabase = WORKFLOW_AUTOMATION_DATABASE
registerDatabase(workflowAutomationDatabase)
_CALLBACK_WORKFLOW_CHANGED = "workflowAutomation.workflow.changed"
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)
+ """Sync invocations with graph trigger nodes (via nodeCatalog, L2)."""
+ from modules.nodeCatalog.entryPoints import invocationsSyncedWithGraph
+ return invocationsSyncedWithGraph(graph, invocations)
def _getWorkflowAutomationInterface(
diff --git a/modules/workflowAutomation/editor/entryPoints.py b/modules/nodeCatalog/entryPoints.py
similarity index 63%
rename from modules/workflowAutomation/editor/entryPoints.py
rename to modules/nodeCatalog/entryPoints.py
index 3b4763f7..b1a8ae03 100644
--- a/modules/workflowAutomation/editor/entryPoints.py
+++ b/modules/nodeCatalog/entryPoints.py
@@ -3,27 +3,28 @@
Workflow entry points (Starts) — configuration outside the flow editor.
Kinds align with run envelope trigger.type where applicable.
+
+Canonical location: modules.nodeCatalog.entryPoints (L2).
+Depends only on stdlib — no cross-module imports.
"""
import uuid
from typing import Any, Dict, List, Optional
-# On-demand (gear: Manueller Trigger, Formular)
KINDS_ON_DEMAND = frozenset({"manual", "form", "api"})
-# Always-on (gear: Zeitplan, Immer aktiv, plus legacy listener kinds)
KINDS_ALWAYS_ON = frozenset({"schedule", "always_on", "email", "webhook", "event"})
ALL_KINDS = KINDS_ON_DEMAND | KINDS_ALWAYS_ON
-def category_for_kind(kind: str) -> str:
+def categoryForKind(kind: str) -> str:
if kind in KINDS_ALWAYS_ON:
return "always_on"
return "on_demand"
-def default_manual_entry_point() -> Dict[str, Any]:
+def defaultManualEntryPoint() -> Dict[str, Any]:
"""Single default manual start when a workflow has no invocations yet."""
return {
"id": str(uuid.uuid4()),
@@ -36,7 +37,7 @@ def default_manual_entry_point() -> Dict[str, Any]:
}
-def _normalize_title(title: Any) -> str:
+def _normalizeTitle(title: Any) -> str:
"""Extract a plain string from a title value for storage (not display)."""
if isinstance(title, dict):
picked = title.get("xx") or next((v for v in title.values() if v), None)
@@ -46,14 +47,14 @@ def _normalize_title(title: Any) -> str:
return "Start"
-def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]:
+def normalizeInvocationEntry(raw: Dict[str, Any]) -> Dict[str, Any]:
"""Validate and normalize a single entry point dict."""
kind = (raw.get("kind") or "manual").strip()
if kind not in ALL_KINDS:
kind = "manual"
cat = raw.get("category")
if cat not in ("on_demand", "always_on"):
- cat = category_for_kind(kind)
+ cat = categoryForKind(kind)
eid = raw.get("id") or str(uuid.uuid4())
enabled = raw.get("enabled", True)
if not isinstance(enabled, bool):
@@ -65,21 +66,21 @@ def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]:
"kind": kind,
"category": cat,
"enabled": enabled,
- "title": _normalize_title(raw.get("title")),
+ "title": _normalizeTitle(raw.get("title")),
"description": desc,
"config": config,
}
-def normalize_invocations_list(items: Optional[List[Any]]) -> List[Dict[str, Any]]:
+def normalizeInvocationsList(items: Optional[List[Any]]) -> List[Dict[str, Any]]:
if not items:
- return [default_manual_entry_point()]
+ return [defaultManualEntryPoint()]
out: List[Dict[str, Any]] = []
for raw in items:
if isinstance(raw, dict):
- out.append(normalize_invocation_entry(raw))
+ out.append(normalizeInvocationEntry(raw))
if not out:
- return [default_manual_entry_point()]
+ return [defaultManualEntryPoint()]
return out
@@ -90,26 +91,36 @@ _NODE_TYPE_TO_KIND = {
}
-def invocations_synced_with_graph(
+def _getTriggerNodes(nodes: List[Dict]) -> List[Dict]:
+ """Return start/trigger nodes: type ``trigger.*``, or category ``trigger`` / ``start``."""
+ return [
+ n
+ for n in nodes
+ if (
+ str(n.get("type", "")).startswith("trigger.")
+ or n.get("category") in ("trigger", "start")
+ )
+ ]
+
+
+def invocationsSyncedWithGraph(
graph: Optional[Dict[str, Any]],
- stored_invocations: Optional[List[Any]],
+ storedInvocations: Optional[List[Any]],
) -> List[Dict[str, Any]]:
"""Derive primary invocation (index 0) from the first start node in ``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.workflowAutomation.engine.graphUtils import getTriggerNodes
-
g = graph if isinstance(graph, dict) else {}
nodes = g.get("nodes") or []
- stored = list(stored_invocations or [])
+ stored = list(storedInvocations or [])
rest: List[Dict[str, Any]] = []
for raw in stored[1:]:
if isinstance(raw, dict):
- rest.append(normalize_invocation_entry(raw))
+ rest.append(normalizeInvocationEntry(raw))
- triggers = getTriggerNodes(nodes)
+ triggers = _getTriggerNodes(nodes)
if not triggers:
return rest
@@ -119,29 +130,28 @@ def invocations_synced_with_graph(
nid = node.get("id")
if not nid:
nid = str(uuid.uuid4())
- raw_title = node.get("title") or node.get("label") or "Start"
+ rawTitle = node.get("title") or node.get("label") or "Start"
- old_primary = stored[0] if stored and isinstance(stored[0], dict) else {}
+ oldPrimary = stored[0] if stored and isinstance(stored[0], dict) else {}
config: Dict[str, Any] = {}
- if isinstance(old_primary.get("config"), dict) and old_primary.get("kind") == kind:
- config = dict(old_primary["config"])
- desc = old_primary.get("description") if isinstance(old_primary.get("description"), dict) else {}
+ if isinstance(oldPrimary.get("config"), dict) and oldPrimary.get("kind") == kind:
+ config = dict(oldPrimary["config"])
+ desc = oldPrimary.get("description") if isinstance(oldPrimary.get("description"), dict) else {}
- primary_raw: Dict[str, Any] = {
+ primaryRaw: Dict[str, Any] = {
"id": str(nid),
"kind": kind,
"enabled": True,
- "title": raw_title,
+ "title": rawTitle,
"description": desc,
"config": config,
}
- primary = normalize_invocation_entry(primary_raw)
+ primary = normalizeInvocationEntry(primaryRaw)
return [primary] + rest
-# POST .../execute with entryPointId set to a schedule entry — no separate in-process scheduler here yet.
-def find_invocation(workflow: Dict[str, Any], entry_point_id: str) -> Optional[Dict[str, Any]]:
+def findInvocation(workflow: Dict[str, Any], entryPointId: str) -> Optional[Dict[str, Any]]:
for inv in workflow.get("invocations") or []:
- if isinstance(inv, dict) and inv.get("id") == entry_point_id:
+ if isinstance(inv, dict) and inv.get("id") == entryPointId:
return inv
return None
diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py
index d193f9bb..143af2e2 100644
--- a/modules/routes/routeBilling.py
+++ b/modules/routes/routeBilling.py
@@ -907,8 +907,8 @@ def createCheckoutSession(
mandateLabel = targetMandateId
invoiceAddress = None
- from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session
- redirect_url = create_checkout_session(
+ from modules.serviceCenter.services.serviceBilling.stripeCheckout import createCheckoutSession
+ redirect_url = createCheckoutSession(
mandate_id=targetMandateId,
user_id=checkoutRequest.userId,
amount_chf=checkoutRequest.amount,
diff --git a/modules/routes/routeClickup.py b/modules/routes/routeClickup.py
index a6c6745d..41797d77 100644
--- a/modules/routes/routeClickup.py
+++ b/modules/routes/routeClickup.py
@@ -9,7 +9,8 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, sta
from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection
from modules.interfaces.interfaceDbApp import getInterface
-from modules.serviceCenter.serviceHub import getInterface as getServices
+from modules.serviceCenter import getService
+from modules.serviceCenter.context import ServiceCenterContext
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeClickup")
@@ -59,13 +60,14 @@ def _clickup_connection_or_404(interface, connection_id: str, user_id: str) -> U
def _svc_for_connection(current_user: User, connection: UserConnection):
- services = getServices(current_user, None)
- if not services.clickup.setAccessTokenFromConnection(connection):
+ ctx = ServiceCenterContext(user=current_user)
+ clickupService = getService("clickup", ctx)
+ if not clickupService.setAccessTokenFromConnection(connection):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=routeApiMsg("Failed to set ClickUp access token. Connection may be expired or invalid."),
)
- return services.clickup
+ return clickupService
@router.get("/{connectionId}/teams/{teamId}", response_model=Dict[str, Any])
diff --git a/modules/routes/routeSharepoint.py b/modules/routes/routeSharepoint.py
index b144328e..328a1bba 100644
--- a/modules/routes/routeSharepoint.py
+++ b/modules/routes/routeSharepoint.py
@@ -12,7 +12,8 @@ from fastapi import APIRouter, HTTPException, Depends, Path, Query, Request, sta
from modules.auth import limiter, getCurrentUser
from modules.datamodels.datamodelUam import User, UserConnection
from modules.interfaces.interfaceDbApp import getInterface
-from modules.serviceCenter.serviceHub import getInterface as getServices
+from modules.serviceCenter import getService
+from modules.serviceCenter.context import ServiceCenterContext
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeSharepoint")
@@ -122,19 +123,17 @@ async def getSharepointFolderOptionsByReference(
detail=f"Connection is not a Microsoft connection (authority: {authority})"
)
- # Initialize services
- services = getServices(currentUser, None)
+ ctx = ServiceCenterContext(user=currentUser)
+ sharepointService = getService("sharepoint", ctx)
- # Set access token on SharePoint service
- if not services.sharepoint.setAccessTokenFromConnection(connection):
+ if not sharepointService.setAccessTokenFromConnection(connection):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=routeApiMsg("Failed to set SharePoint access token. Connection may be expired or invalid.")
)
- # Mode 1: Return sites list if no siteId specified
if not siteId:
- sites = await services.sharepoint.discoverSites()
+ sites = await sharepointService.discoverSites()
return [
{
"type": "site",
@@ -148,9 +147,8 @@ async def getSharepointFolderOptionsByReference(
for site in sites
]
- # Mode 2: Return folders within specific site
folderPath = path or ""
- items = await services.sharepoint.listFolderContents(siteId, folderPath)
+ items = await sharepointService.listFolderContents(siteId, folderPath)
if not items:
return []
diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py
index 8529206b..853c4b32 100644
--- a/modules/routes/routeSystem.py
+++ b/modules/routes/routeSystem.py
@@ -839,12 +839,12 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]:
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelPagination import PaginationParams
from modules.datamodels.datamodelWorkflowAutomation import (
- AutoWorkflow, AutoRun,
+ AutoWorkflow, AutoRun, WORKFLOW_AUTOMATION_DATABASE,
)
wfDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
- dbDatabase="poweron_graphicaleditor",
+ dbDatabase=WORKFLOW_AUTOMATION_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)),
diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py
index 81c009fb..8a9dd587 100644
--- a/modules/routes/routeWorkflowAutomation.py
+++ b/modules/routes/routeWorkflowAutomation.py
@@ -31,7 +31,7 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginationM
from modules.datamodels.datamodelWorkflowAutomation import (
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
)
-from modules.dbHelpers.paginationHelpers import applyFiltersAndSort
+from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, paginateInMemory
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.i18nRegistry import apiRouteContext, resolveText
from modules.workflowAutomation.helpers import (
@@ -75,9 +75,12 @@ async def _listWorkflows(
scopeFilter = {"mandateId": mandateId}
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}
+ records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter)
+ if params:
+ filtered = applyFiltersAndSort(records or [], params)
+ pageItems, totalItems = paginateInMemory(filtered, params)
+ return {"items": pageItems, "total": totalItems}
+ return {"items": records or [], "total": len(records or [])}
finally:
db.close()
@@ -181,9 +184,12 @@ async def _listRuns(
scopeFilter = {**(scopeFilter or {}), "workflowId": workflowId}
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}
+ records = db.getRecordset(AutoRun, recordFilter=scopeFilter)
+ if params:
+ filtered = applyFiltersAndSort(records or [], params)
+ pageItems, totalItems = paginateInMemory(filtered, params)
+ return {"items": pageItems, "total": totalItems}
+ return {"items": records or [], "total": len(records or [])}
finally:
db.close()
@@ -234,9 +240,12 @@ async def _listTasks(
scopeFilter = {**(scopeFilter or {}), "status": status}
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}
+ records = db.getRecordset(AutoTask, recordFilter=scopeFilter)
+ if params:
+ filtered = applyFiltersAndSort(records or [], params)
+ pageItems, totalItems = paginateInMemory(filtered, params)
+ return {"items": pageItems, "total": totalItems}
+ return {"items": records or [], "total": len(records or [])}
finally:
db.close()
@@ -1243,11 +1252,11 @@ def _getRunDetail(
except Exception as e:
logger.warning("_getRunDetail: file lookup failed: %s", e)
- from modules.workflowAutomation.engine.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui
+ from modules.shared.workflowArtifactVisibility import suppressWorkflowFileInWorkspaceUi
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)]
+ return [m for m in rows if not suppressWorkflowFileInWorkspaceUi(m)]
assignedFileIds: set = set()
for step, (inputIds, outputIds) in zip(steps, perStepFileIds):
@@ -1305,7 +1314,7 @@ def _buildExecuteRunEnvelope(
merge_run_envelope,
normalize_run_envelope,
)
- from modules.workflowAutomation.editor.entryPoints import find_invocation
+ from modules.nodeCatalog.entryPoints import findInvocation
if isinstance(body.get("runEnvelope"), dict):
env = normalize_run_envelope(body["runEnvelope"], user_id=userId)
@@ -1321,7 +1330,7 @@ def _buildExecuteRunEnvelope(
status_code=400,
detail=routeApiMsg("entryPointId requires a saved workflow (workflowId must refer to a stored workflow)"),
)
- inv = find_invocation(workflow, entryPointId)
+ inv = findInvocation(workflow, entryPointId)
if not inv:
raise HTTPException(status_code=400, detail=routeApiMsg("entryPointId not found on workflow"))
if not inv.get("enabled", True):
diff --git a/modules/serviceCenter/__init__.py b/modules/serviceCenter/__init__.py
index fb40df65..a2590fc6 100644
--- a/modules/serviceCenter/__init__.py
+++ b/modules/serviceCenter/__init__.py
@@ -16,9 +16,10 @@ from modules.serviceCenter.registry import (
)
from modules.serviceCenter.resolver import (
resolve,
- get_resolution_cache,
- clear_cache,
+ getResolutionCache,
+ clearCache,
)
+from modules.serviceCenter.services.serviceAgent.mainServiceAgent import ServicesBag
logger = logging.getLogger(__name__)
@@ -37,7 +38,7 @@ def getService(
Returns:
Service instance
"""
- cache = get_resolution_cache()
+ cache = getResolutionCache()
resolving = set()
return resolve(key, context, cache, resolving)
@@ -80,13 +81,13 @@ def registerServiceObjects(catalogService) -> bool:
return False
-def can_access_service(
+def canAccessService(
user,
rbac,
- service_key: str,
- mandate_id: Optional[str] = None,
- feature_instance_id: Optional[str] = None,
- allow_when_no_rbac: bool = True,
+ serviceKey: str,
+ mandateId: Optional[str] = None,
+ featureInstanceId: Optional[str] = None,
+ allowWhenNoRbac: bool = True,
) -> bool:
"""
Check if user has permission to access the given service.
@@ -94,40 +95,42 @@ def can_access_service(
Args:
user: User object
rbac: RbacClass instance (e.g. from interfaceDbApp.rbac)
- service_key: Service key (e.g., "web", "extraction")
- mandate_id: Optional mandate context
- feature_instance_id: Optional feature instance context
- allow_when_no_rbac: If True, allow when rbac is None (migration/default)
+ serviceKey: Service key (e.g., "web", "extraction")
+ mandateId: Optional mandate context
+ featureInstanceId: Optional feature instance context
+ allowWhenNoRbac: If True, allow when rbac is None (migration/default)
Returns:
True if user has view permission on the service
"""
if not rbac:
- return allow_when_no_rbac
- if service_key not in IMPORTABLE_SERVICES:
+ return allowWhenNoRbac
+ if serviceKey not in IMPORTABLE_SERVICES:
return False
- obj = IMPORTABLE_SERVICES[service_key]
- object_key = obj.get("objectKey")
- if not object_key:
+ obj = IMPORTABLE_SERVICES[serviceKey]
+ objectKey = obj.get("objectKey")
+ if not objectKey:
return False
from modules.datamodels.datamodelRbac import AccessRuleContext
permissions = rbac.getUserPermissions(
user,
AccessRuleContext.RESOURCE,
- object_key,
- mandateId=mandate_id,
- featureInstanceId=feature_instance_id,
+ objectKey,
+ mandateId=mandateId,
+ featureInstanceId=featureInstanceId,
)
return permissions.view if permissions else False
+
__all__ = [
"ServiceCenterContext",
+ "ServicesBag",
"getService",
"preWarm",
- "clear_cache",
+ "clearCache",
"registerServiceObjects",
- "can_access_service",
+ "canAccessService",
"SERVICE_RBAC_OBJECTS",
"CORE_SERVICES",
"IMPORTABLE_SERVICES",
diff --git a/modules/serviceCenter/resolver.py b/modules/serviceCenter/resolver.py
index 5d400760..316ce052 100644
--- a/modules/serviceCenter/resolver.py
+++ b/modules/serviceCenter/resolver.py
@@ -75,18 +75,21 @@ except ImportError:
pass
-def get_resolution_cache() -> Dict[str, Any]:
+def getResolutionCache() -> Dict[str, Any]:
"""Get the module-level resolution cache (for preWarm/clear)."""
return _resolution_cache
-def clear_cache() -> None:
+
+def clearCache() -> None:
"""Clear the resolution cache."""
lock = _cache_lock if _cache_lock is not None else _DummyLock()
with lock:
_resolution_cache.clear()
+
+
class _DummyLock:
def __enter__(self):
return self
diff --git a/modules/serviceCenter/serviceHub.py b/modules/serviceCenter/serviceHub.py
deleted file mode 100644
index a42f8d0e..00000000
--- a/modules/serviceCenter/serviceHub.py
+++ /dev/null
@@ -1,189 +0,0 @@
-# Copyright (c) 2025 Patrick Motsch
-# All rights reserved.
-"""
-Service Hub.
-Consumer-facing aggregation layer for services, DB interfaces, and runtime state.
-
-Architecture:
-- serviceHub delegates service resolution to serviceCenter (DI container)
-- serviceHub owns DB interface initialization and runtime state
-- serviceCenter knows nothing about serviceHub (one-way dependency)
-
-Import-Regelwerk:
-- Zentrale Module (wie dieses) duerfen KEINE Feature-Container importieren
-- Feature-spezifische Services werden dynamisch geladen
-- Shared Services werden via serviceCenter resolved
-"""
-
-import os
-import importlib
-import glob
-from typing import Any, Optional, TYPE_CHECKING
-import logging
-
-from modules.datamodels.datamodelUam import User
-
-if TYPE_CHECKING:
- from modules.datamodels.datamodelChat import ChatWorkflow
-
-logger = logging.getLogger(__name__)
-
-_FEATURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "features")
-
-
-class PublicService:
- """Lightweight proxy exposing only public callable attributes of a target."""
-
- def __init__(self, target: Any, functionsOnly: bool = True, nameFilter=None):
- self._target = target
- self._functionsOnly = functionsOnly
- self._nameFilter = nameFilter
-
- def __getattr__(self, name: str):
- if name.startswith('_'):
- raise AttributeError(f"'{type(self._target).__name__}' attribute '{name}' is private")
- if self._nameFilter and not self._nameFilter(name):
- raise AttributeError(f"'{name}' not exposed by policy")
- attr = getattr(self._target, name)
- if self._functionsOnly and not callable(attr):
- raise AttributeError(f"'{name}' is not a function")
- return attr
-
- def __dir__(self):
- return sorted([
- n for n in dir(self._target)
- if not n.startswith('_')
- and (not self._functionsOnly or callable(getattr(self._target, n, None)))
- and (self._nameFilter(n) if self._nameFilter else True)
- ])
-
-
-class ServiceHub:
- """
- Consumer-facing aggregation of services, DB interfaces, and runtime state.
-
- Services are lazy-resolved via serviceCenter on first access.
- DB interfaces and runtime state are initialized eagerly.
- Feature services/interfaces are discovered dynamically from features/.
- """
-
- _SERVICE_CENTER_WRAPPING = {
- "ai": {"functionsOnly": False},
- }
-
- def __init__(self, user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
- self.user: User = user
- self.workflow = workflow
- self.mandateId: Optional[str] = mandateId
- self.featureInstanceId: Optional[str] = featureInstanceId
- self.currentUserPrompt: str = ""
- self.rawUserPrompt: str = ""
-
- from modules.serviceCenter.context import ServiceCenterContext
- self._serviceCenterContext = ServiceCenterContext(
- user=user,
- workflow=workflow,
- mandate_id=mandateId,
- feature_instance_id=featureInstanceId,
- )
-
- from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
- self.interfaceDbApp = getAppInterface(user, mandateId=mandateId)
-
- from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
- self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId)
-
- self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None
-
- from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
- self.interfaceDbChat = getChatInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId)
-
- self._loadFeatureInterfaces()
- self._loadFeatureServices()
-
- def __getattr__(self, name: str):
- """Lazy-resolve services via serviceCenter on first access."""
- if name.startswith('_'):
- raise AttributeError(name)
- try:
- from modules.serviceCenter import getService
- service = getService(name, self._serviceCenterContext)
- wrapping = self._SERVICE_CENTER_WRAPPING.get(name, {})
- functionsOnly = wrapping.get("functionsOnly", True)
- wrapped = PublicService(service, functionsOnly=functionsOnly)
- setattr(self, name, wrapped)
- return wrapped
- except KeyError:
- raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
-
- def _loadFeatureInterfaces(self):
- """Dynamically load interfaces from feature containers by filename pattern."""
- pattern = os.path.join(_FEATURES_DIR, "*", "interfaceFeature*.py")
- for filepath in glob.glob(pattern):
- try:
- featureDir = os.path.basename(os.path.dirname(filepath))
- filename = os.path.basename(filepath)[:-3]
-
- modulePath = f"modules.features.{featureDir}.{filename}"
- module = importlib.import_module(modulePath)
-
- if hasattr(module, "getInterface"):
- interface = module.getInterface(self.user, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
- attrName = filename.replace("interfaceFeature", "interfaceDb")
- setattr(self, attrName, interface)
- logger.debug(f"Loaded interface: {attrName} from {modulePath}")
- except Exception as e:
- logger.debug(f"Could not load interface from {filepath}: {e}")
-
- def _loadFeatureServices(self):
- """Dynamically load services from feature containers by filename pattern."""
- pattern = os.path.join(_FEATURES_DIR, "*", "service*", "mainService*.py")
- for filepath in glob.glob(pattern):
- try:
- serviceDir = os.path.basename(os.path.dirname(filepath))
- featureDir = os.path.basename(os.path.dirname(os.path.dirname(filepath)))
- filename = os.path.basename(filepath)[:-3]
-
- modulePath = f"modules.features.{featureDir}.{serviceDir}.{filename}"
- module = importlib.import_module(modulePath)
-
- serviceClass = None
- for attrName in dir(module):
- if attrName.endswith("Service") and not attrName.startswith("_"):
- cls = getattr(module, attrName)
- if isinstance(cls, type):
- serviceClass = cls
- break
-
- if serviceClass:
- attrName = serviceDir.replace("service", "").lower()
- if not attrName:
- attrName = serviceDir.lower()
-
- functionsOnly = attrName != "ai"
-
- def _makeServiceResolver(hub):
- def _resolver(depKey: str):
- return getattr(hub, depKey)
- return _resolver
-
- import inspect
- sig = inspect.signature(serviceClass.__init__)
- paramCount = len([p for p in sig.parameters if p != 'self'])
- if paramCount >= 2:
- serviceInstance = serviceClass(self, _makeServiceResolver(self))
- else:
- serviceInstance = serviceClass(self)
- setattr(self, attrName, PublicService(serviceInstance, functionsOnly=functionsOnly))
- logger.debug(f"Loaded service: {attrName} from {modulePath}")
- except Exception as e:
- logger.debug(f"Could not load service from {filepath}: {e}")
-
-
-# Backward-compatible alias
-Services = ServiceHub
-
-
-def getInterface(user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ServiceHub:
- """Get ServiceHub instance for the given user, mandate, and feature instance context."""
- return ServiceHub(user, workflow, mandateId=mandateId, featureInstanceId=featureInstanceId)
diff --git a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py
index 9389ee85..4cfbb8c4 100644
--- a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py
+++ b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py
@@ -274,7 +274,7 @@ def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[Di
docName = getattr(doc, "documentName", "unnamed")
docMime = getattr(doc, "mimeType", "application/octet-stream")
try:
- fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docBytes, docName)
+ fileItem, _ = chatService.saveUploadedFile(docBytes, docName)
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
_attachFileAsChatDocument,
@@ -295,7 +295,7 @@ def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[Di
updateFields["mandateId"] = mandateId
if updateFields:
logger.debug("_persistLargeDocument: updating file %s with %s", fileItem.id, updateFields)
- chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields)
+ chatService.updateFile(fileItem.id, updateFields)
else:
logger.warning("_persistLargeDocument: no updateFields for file %s (tempFolderId=%s, fiId=%s)", fileItem.id, tempFolderId, fiId)
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py
index 4bb97de9..0a9e678b 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py
@@ -88,12 +88,11 @@ def registerConnectionTools(registry: ToolRegistry, services):
graphAttachments: List[Dict[str, Any]] = []
if attachmentFileIds:
chatService = services.chat
- dbMgmt = chatService.interfaceDbComponent
for fid in attachmentFileIds:
- fileRow = dbMgmt.getFile(fid)
+ fileRow = chatService.getFile(fid)
if not fileRow:
return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file not found: {fid}")
- rawBytes = dbMgmt.getFileData(fid)
+ rawBytes = chatService.getFileData(fid)
if not rawBytes:
return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file has no data: {fid}")
graphAttachments.append({
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py
index 055a4055..2675257c 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py
@@ -27,8 +27,7 @@ def registerCrossWorkflowTools(registry: ToolRegistry, services):
"""List all chat workflows in this workspace with metadata."""
try:
chatService = services.chat
- chatInterface = chatService.interfaceDbChat
- allWorkflows = chatInterface.getWorkflows() or []
+ allWorkflows = chatService.getWorkflows() or []
allWorkflows.sort(
key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0,
@@ -43,7 +42,7 @@ def registerCrossWorkflowTools(registry: ToolRegistry, services):
createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0
lastActivity = wf.get("lastActivity") or createdAt
- msgs = chatInterface.getMessages(wfId) or []
+ msgs = chatService.getMessages(wfId) or []
messageCount = len(msgs)
lastPreview = ""
if msgs:
@@ -102,8 +101,7 @@ def registerCrossWorkflowTools(registry: ToolRegistry, services):
try:
chatService = services.chat
- chatInterface = chatService.interfaceDbChat
- allMsgs = chatInterface.getMessages(targetWorkflowId) or []
+ allMsgs = chatService.getMessages(targetWorkflowId) or []
sliced = allMsgs[offset:offset + limit]
items = []
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
index 291f33dc..76fd0bae 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
@@ -359,7 +359,7 @@ def registerDataSourceTools(registry: ToolRegistry, services):
elif fileBytes[:2] == b"PK":
fileName = f"{fileName}.zip"
chatService = services.chat
- fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName)
+ fileItem, _ = chatService.saveUploadedFile(fileBytes, fileName)
updateFields = {}
tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId:
@@ -370,7 +370,7 @@ def registerDataSourceTools(registry: ToolRegistry, services):
if _sourceNeutralize:
updateFields["neutralize"] = True
if updateFields:
- chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields)
+ chatService.updateFile(fileItem.id, updateFields)
chatDocId = _attachFileAsChatDocument(
services, fileItem,
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
index e6efad99..2dfc4686 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
@@ -173,11 +173,6 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services):
neutralizePolicy[tn] = {"tableActive": tableActive, "explicitFields": explicitFields}
neutralizationService = services.getService("neutralization") if hasattr(services, "getService") else None
- if neutralizationService is not None and not getattr(neutralizationService, "interfaceDbComponent", None):
- try:
- neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent
- except Exception:
- pass
cacheKey = f"{featureInstanceId}:{hashlib.md5(question.encode()).hexdigest()}"
if cacheKey in _featureQueryCache:
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py
index ea80fdc7..4e69d849 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py
@@ -48,23 +48,16 @@ def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool:
def _getOrCreateTempFolder(chatService) -> Optional[str]:
"""Return the ID of the user's 'Temp' folder, creating it if it doesn't exist."""
- ifc = getattr(chatService, "interfaceDbComponent", None)
- if not ifc:
- logger.warning("_getOrCreateTempFolder: no interfaceDbComponent on chatService")
- return None
- userId = getattr(ifc, "userId", None)
- if not userId:
- logger.warning("_getOrCreateTempFolder: userId is None on interfaceDbComponent")
- return None
try:
- ownFolders = ifc.getOwnFolderTree()
+ ownFolders = chatService.getOwnFolderTree()
for f in ownFolders:
if f.get("name") == "Temp":
folderId = f.get("id")
logger.debug("_getOrCreateTempFolder: found existing Temp folder %s", folderId)
return str(folderId) if folderId else None
- newFolder = ifc.createFolder("Temp")
+ newFolder = chatService.createFolder("Temp")
folderId = newFolder.get("id") if isinstance(newFolder, dict) else getattr(newFolder, "id", None)
+ userId = getattr(getattr(chatService, "interfaceDbComponent", None), "userId", None)
logger.info("_getOrCreateTempFolder: created Temp folder %s for user %s", folderId, userId)
return str(folderId) if folderId else None
except Exception as e:
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py
index 380c9950..e3978b72 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py
@@ -46,8 +46,8 @@ def registerMediaTools(registry: ToolRegistry, services):
if sourceFileId:
try:
- dbMgmt = services.chat.interfaceDbComponent
- fileRow = dbMgmt.getFile(sourceFileId)
+ chatService = services.chat
+ fileRow = chatService.getFile(sourceFileId)
if not fileRow:
return ToolResult(
toolCallId="",
@@ -55,7 +55,7 @@ def registerMediaTools(registry: ToolRegistry, services):
success=False,
error=f"sourceFileId not found: {sourceFileId}",
)
- rawBytes = dbMgmt.getFileData(sourceFileId)
+ rawBytes = chatService.getFileData(sourceFileId)
if not rawBytes:
return ToolResult(
toolCallId="",
@@ -244,11 +244,7 @@ def registerMediaTools(registry: ToolRegistry, services):
if not docName.lower().endswith(f".{outputFormat}"):
docName = f"{sanitizedTitle}.{outputFormat}"
- fileItem = None
- if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"):
- fileItem = chatService.interfaceDbComponent.saveGeneratedFile(docData, docName, docMime)
- else:
- fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docData, docName)
+ fileItem, _ = chatService.saveUploadedFile(docData, docName)
if fileItem:
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
@@ -260,7 +256,7 @@ def registerMediaTools(registry: ToolRegistry, services):
if fiId:
updateFields["featureInstanceId"] = fiId
if updateFields:
- chatService.interfaceDbComponent.updateFile(fid, updateFields)
+ chatService.updateFile(fid, updateFields)
chatDocId = _attachFileAsChatDocument(
services, fileItem,
label=f"renderDocument:{docName}",
@@ -544,11 +540,7 @@ def registerMediaTools(registry: ToolRegistry, services):
if not docName.lower().endswith(".png"):
docName = f"{sanitizedTitle}.png"
- fileItem = None
- if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"):
- fileItem = chatService.interfaceDbComponent.saveGeneratedFile(docData, docName, docMime)
- else:
- fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docData, docName)
+ fileItem, _ = chatService.saveUploadedFile(docData, docName)
if fileItem:
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
@@ -560,7 +552,7 @@ def registerMediaTools(registry: ToolRegistry, services):
if fiId:
updateFields["featureInstanceId"] = fiId
if updateFields:
- chatService.interfaceDbComponent.updateFile(fid, updateFields)
+ chatService.updateFile(fid, updateFields)
chatDocId = _attachFileAsChatDocument(
services, fileItem,
label=f"generateImage:{docName}",
@@ -709,10 +701,7 @@ def registerMediaTools(registry: ToolRegistry, services):
sanitizedTitle = re.sub(r'[^\w._-]', '_', title, flags=re.UNICODE).strip('_') or "chart"
fileName = f"{sanitizedTitle}.png"
- if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"):
- fileItem = chatService.interfaceDbComponent.saveGeneratedFile(pngData, fileName, "image/png")
- else:
- fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName)
+ fileItem, _ = chatService.saveUploadedFile(pngData, fileName)
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?"
if fid != "?":
@@ -724,7 +713,7 @@ def registerMediaTools(registry: ToolRegistry, services):
if fiId:
updateFields["featureInstanceId"] = fiId
if updateFields:
- chatService.interfaceDbComponent.updateFile(fid, updateFields)
+ chatService.updateFile(fid, updateFields)
chatDocId = _attachFileAsChatDocument(
services, fileItem,
@@ -811,7 +800,7 @@ def registerMediaTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="speechToText", success=False, error="fileId is required")
try:
chatService = services.chat
- audioData = chatService.interfaceDbComponent.getFileData(fileId)
+ audioData = chatService.getFileData(fileId)
if not audioData:
return ToolResult(toolCallId="", toolName="speechToText", success=False, error=f"No data found for file {fileId}")
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
@@ -855,8 +844,6 @@ def registerMediaTools(registry: ToolRegistry, services):
neutralizationService = services.getService("neutralization")
if not neutralizationService:
return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization service not available")
- if not neutralizationService.interfaceDbComponent:
- neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent
if text:
result = await neutralizationService.processTextAsync(text, fileId or None)
else:
@@ -890,16 +877,13 @@ def registerMediaTools(registry: ToolRegistry, services):
if not neutralizationService or not hasattr(neutralizationService, "resolveText"):
return ToolResult(toolCallId="", toolName="revealDocument", success=False,
error="Neutralization service not available")
- if not getattr(neutralizationService, "interfaceDbComponent", None):
- neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent
-
if fileId and not text:
- dbMgmt = services.chat.interfaceDbComponent
- fileRow = dbMgmt.getFile(fileId)
+ chatService = services.chat
+ fileRow = chatService.getFile(fileId)
if not fileRow:
return ToolResult(toolCallId="", toolName="revealDocument", success=False,
error=f"fileId not found: {fileId}")
- rawBytes = dbMgmt.getFileData(fileId)
+ rawBytes = chatService.getFileData(fileId)
if not rawBytes:
return ToolResult(toolCallId="", toolName="revealDocument", success=False,
error="File data not accessible")
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py
index 9b4d2818..a1d56e24 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py
@@ -283,7 +283,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="tagFile", success=False, error="fileId is required")
try:
chatService = services.chat
- chatService.interfaceDbComponent.updateFile(fileId, {"tags": tags})
+ chatService.updateFile(fileId, {"tags": tags})
return ToolResult(
toolCallId="", toolName="tagFile", success=True,
data=f"Tags updated to {tags} for file {fileId}"
@@ -302,22 +302,21 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
try:
chatService = services.chat
- dbMgmt = chatService.interfaceDbComponent
if mode == "append":
if not fileId:
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="fileId is required for mode=append")
- file = dbMgmt.getFile(fileId)
+ file = chatService.getFile(fileId)
if not file:
return ToolResult(toolCallId="", toolName="writeFile", success=False, error=f"File {fileId} not found")
- existingData = dbMgmt.getFileData(fileId) or b""
+ existingData = chatService.getFileData(fileId) or b""
try:
existingText = existingData.decode("utf-8")
except UnicodeDecodeError:
existingText = existingData.decode("latin-1", errors="replace")
newContent = existingText + content
- dbMgmt.updateFileData(fileId, newContent.encode("utf-8"))
- dbMgmt.updateFile(fileId, {"fileSize": len(newContent.encode("utf-8"))})
+ chatService.updateFileData(fileId, newContent.encode("utf-8"))
+ chatService.updateFile(fileId, {"fileSize": len(newContent.encode("utf-8"))})
return ToolResult(
toolCallId="", toolName="writeFile", success=True,
data=f"Appended {len(content)} chars to '{file.fileName}' (id: {fileId}, total: {len(newContent)} chars)",
@@ -327,11 +326,11 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
if mode == "overwrite":
if not fileId:
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="fileId is required for mode=overwrite")
- file = dbMgmt.getFile(fileId)
+ file = chatService.getFile(fileId)
if not file:
return ToolResult(toolCallId="", toolName="writeFile", success=False, error=f"File {fileId} not found")
- dbMgmt.updateFileData(fileId, content.encode("utf-8"))
- dbMgmt.updateFile(fileId, {"fileSize": len(content.encode("utf-8"))})
+ chatService.updateFileData(fileId, content.encode("utf-8"))
+ chatService.updateFile(fileId, {"fileSize": len(content.encode("utf-8"))})
return ToolResult(
toolCallId="", toolName="writeFile", success=True,
data=f"Overwritten '{file.fileName}' (id: {fileId}, {len(content)} chars)",
@@ -341,7 +340,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
# mode == "create" (default)
if not name:
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required for mode=create")
- fileItem, _ = dbMgmt.saveUploadedFile(content.encode("utf-8"), name)
+ fileItem, _ = chatService.saveUploadedFile(content.encode("utf-8"), name)
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
updateFields: Dict[str, Any] = {}
if fiId:
@@ -351,7 +350,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
if args.get("tags"):
updateFields["tags"] = args["tags"]
if updateFields:
- dbMgmt.updateFile(fileItem.id, updateFields)
+ chatService.updateFile(fileItem.id, updateFields)
chatDocId = _attachFileAsChatDocument(
services, fileItem,
@@ -498,7 +497,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="deleteFile", success=False, error="fileId is required")
try:
chatService = services.chat
- file = chatService.interfaceDbComponent.getFile(fileId)
+ file = chatService.getFile(fileId)
if not file:
return ToolResult(toolCallId="", toolName="deleteFile", success=False, error=f"File {fileId} not found")
fileName = file.fileName
@@ -508,7 +507,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
knowledgeService.removeFile(fileId)
except Exception as e:
logger.warning(f"deleteFile: knowledge store cleanup failed for {fileId}: {e}")
- chatService.interfaceDbComponent.deleteFile(fileId)
+ chatService.deleteFile(fileId)
return ToolResult(
toolCallId="", toolName="deleteFile", success=True,
data=f"File '{fileName}' (id: {fileId}) deleted",
@@ -524,7 +523,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="renameFile", success=False, error="fileId and newName are required")
try:
chatService = services.chat
- chatService.interfaceDbComponent.updateFile(fileId, {"fileName": newName})
+ chatService.updateFile(fileId, {"fileName": newName})
return ToolResult(
toolCallId="", toolName="renameFile", success=True,
data=f"File {fileId} renamed to '{newName}'",
@@ -651,7 +650,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="copyFile", success=False, error="fileId is required")
try:
chatService = services.chat
- copiedFile = chatService.interfaceDbComponent.copyFile(
+ copiedFile = chatService.copyFile(
fileId,
newFileName=args.get("newFileName"),
)
@@ -676,16 +675,15 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="fileId and oldText are required")
try:
chatService = services.chat
- dbMgmt = chatService.interfaceDbComponent
- file = dbMgmt.getFile(fileId)
+ file = chatService.getFile(fileId)
if not file:
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error=f"File {fileId} not found")
- if not dbMgmt.isTextMimeType(file.mimeType):
+ if not chatService.isTextMimeType(file.mimeType):
return ToolResult(
toolCallId="", toolName="replaceInFile", success=False,
error=f"Cannot edit binary file ({file.mimeType}). Only text-based files are supported."
)
- rawData = dbMgmt.getFileData(fileId)
+ rawData = chatService.getFileData(fileId)
if not rawData:
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="File has no content")
try:
@@ -750,8 +748,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="createFolder", success=False, error="name is required")
try:
chatService = services.chat
- dbMgmt = chatService.interfaceDbComponent
- folder = dbMgmt.createFolder(name, parentId=parentId)
+ folder = chatService.createFolder(name, parentId=parentId)
folderId = folder.get("id") if isinstance(folder, dict) else getattr(folder, "id", None)
folderName = folder.get("name") if isinstance(folder, dict) else getattr(folder, "name", name)
return ToolResult(
@@ -765,8 +762,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
async def _listFolders(args: Dict[str, Any], context: Dict[str, Any]):
try:
chatService = services.chat
- dbMgmt = chatService.interfaceDbComponent
- folders = dbMgmt.getOwnFolderTree()
+ folders = chatService.getOwnFolderTree()
if not folders:
return ToolResult(toolCallId="", toolName="listFolders", success=True, data="No folders found.")
lines = []
@@ -795,11 +791,10 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="moveFile", success=False, error="fileId is required")
try:
chatService = services.chat
- dbMgmt = chatService.interfaceDbComponent
- file = dbMgmt.getFile(fileId)
+ file = chatService.getFile(fileId)
if not file:
return ToolResult(toolCallId="", toolName="moveFile", success=False, error=f"File {fileId} not found")
- dbMgmt.updateFile(fileId, {"folderId": folderId or None})
+ chatService.updateFile(fileId, {"folderId": folderId or None})
targetLabel = f"folder {folderId}" if folderId else "root"
return ToolResult(
toolCallId="", toolName="moveFile", success=True,
@@ -843,8 +838,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="renameFolder", success=False, error="folderId and newName are required")
try:
chatService = services.chat
- dbMgmt = chatService.interfaceDbComponent
- folder = dbMgmt.renameFolder(folderId, newName)
+ folder = chatService.renameFolder(folderId, newName)
return ToolResult(
toolCallId="", toolName="renameFolder", success=True,
data=f"Folder {folderId} renamed to '{newName}'",
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index e0c57496..e977f596 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -26,7 +26,7 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
logger = logging.getLogger(__name__)
-def _toolbox_connection_authorities(services: "_ServicesAdapter") -> List[str]:
+def _toolbox_connection_authorities(services: "ServicesBag") -> List[str]:
"""Collect connection authority strings for toolbox gating (requiresConnection).
The optional ``connection`` service is not always registered; fall back to
@@ -59,8 +59,10 @@ def _toolbox_connection_authorities(services: "_ServicesAdapter") -> List[str]:
return list(seen)
-class _ServicesAdapter:
- """Adapter providing service access from (context, get_service)."""
+class ServicesBag:
+ """Canonical services bag providing service access from (context, get_service).
+ Used by AgentService and WorkflowAutomation as the single source of truth
+ for service resolution, RBAC checks, and context-scoped properties."""
def __init__(self, context, getService: Callable[[str], Any]):
self._context = context
@@ -105,13 +107,6 @@ class _ServicesAdapter:
def extraction(self):
return self._getService("extraction")
- @property
- def interfaceDbComponent(self):
- try:
- return self.chat.interfaceDbComponent
- except Exception:
- return None
-
@property
def rbac(self):
"""Same RbacClass as workflow hub (MethodBase permission checks during discoverMethods)."""
@@ -128,6 +123,15 @@ class _ServicesAdapter:
"""Access any service by name."""
return self._getService(name)
+ def canAccessService(self, serviceKey: str) -> bool:
+ """Check if current user has RBAC permission for a service."""
+ from modules.serviceCenter import canAccessService
+ return canAccessService(
+ self.user, self.rbac, serviceKey,
+ mandateId=self.mandateId,
+ featureInstanceId=self.featureInstanceId,
+ )
+
def __getattr__(self, name: str):
"""Resolve e.g. services.clickup for MethodClickup / ActionExecutor (discoverMethods)."""
if name.startswith("_"):
@@ -157,7 +161,7 @@ class AgentService:
def __init__(self, context, get_service: Callable[[str], Any]):
self._context = context
self._getService = get_service
- self.services = _ServicesAdapter(context, get_service)
+ self.services = ServicesBag(context, get_service)
async def runAgent(
self,
@@ -676,8 +680,7 @@ def _buildWorkflowHintItems(
Limited to 10 most recent other workflows to keep the hint small.
"""
try:
- chatInterface = services.chat.interfaceDbChat
- allWorkflows = chatInterface.getWorkflows() or []
+ allWorkflows = services.chat.getWorkflows() or []
except Exception:
return []
diff --git a/modules/serviceCenter/services/serviceAi/subContentExtraction.py b/modules/serviceCenter/services/serviceAi/subContentExtraction.py
index 6e5ddd42..d66db1cc 100644
--- a/modules/serviceCenter/services/serviceAi/subContentExtraction.py
+++ b/modules/serviceCenter/services/serviceAi/subContentExtraction.py
@@ -261,7 +261,7 @@ class ContentExtractor:
# Check if it's standardized JSON format (has "documents" or "sections")
if document.mimeType == "application/json":
- docBytes = self.services.interfaceDbComponent.getFileData(document.fileId)
+ docBytes = self.services.chat.getFileData(document.fileId)
if docBytes:
try:
docData = docBytes.decode('utf-8')
@@ -349,7 +349,7 @@ class ContentExtractor:
if document.mimeType.startswith("image/") or self._isBinary(document.mimeType):
try:
# Lade Binary-Daten (getFileData ist nicht async - keine await nötig)
- binaryData = self.services.interfaceDbComponent.getFileData(document.fileId)
+ binaryData = self.services.chat.getFileData(document.fileId)
if not binaryData:
logger.warning(f"No binary data found for document {document.id}")
continue
diff --git a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py
index 7d47c18f..aae86fc2 100644
--- a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py
+++ b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py
@@ -155,7 +155,7 @@ class DocumentIntentAnalyzer:
return None
try:
- docBytes = self.services.interfaceDbComponent.getFileData(document.fileId)
+ docBytes = self.services.chat.getFileData(document.fileId)
if not docBytes:
return None
diff --git a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py
index bb9feea7..010c4e4b 100644
--- a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py
+++ b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py
@@ -212,7 +212,7 @@ def _normalizeReturnUrl(returnUrl: str) -> str:
return urlunsplit((parsed.scheme, parsed.netloc, normalized_path, normalized_query, ""))
-def create_checkout_session(
+def createCheckoutSession(
mandate_id: str,
user_id: Optional[str],
amount_chf: float,
diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
index 3e3d9f15..77856a7d 100644
--- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py
+++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
@@ -788,6 +788,151 @@ class ChatService:
'workflowId': 'unknown'
}
+ def createActionItem(self, actionData: Dict[str, Any]):
+ """Create an ActionItem record in the chat DB.
+ Encapsulates low-level _separateObjectFields + db.recordCreate so callers
+ never need direct interfaceDbChat access."""
+ from modules.datamodels.datamodelChat import ActionItem
+ simpleFields, _objectFields = self.interfaceDbChat._separateObjectFields(ActionItem, actionData)
+ return self.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
+
+ def getUserConnectionById(self, connectionId: str):
+ """Get a single UserConnection by ID, delegating to interfaceDbApp."""
+ try:
+ if self.interfaceDbApp and hasattr(self.interfaceDbApp, "getUserConnectionById"):
+ return self.interfaceDbApp.getUserConnectionById(str(connectionId))
+ except Exception as e:
+ logger.error(f"Error getting user connection by ID {connectionId}: {e}")
+ return None
+
+ # ---- File-Write operations (delegate to interfaceDbComponent) ----
+
+ def saveUploadedFile(self, fileContent: bytes, fileName: str):
+ """Save uploaded file bytes. Returns (fileItem, duplicateStatus)."""
+ try:
+ return self.interfaceDbComponent.saveUploadedFile(fileContent, fileName)
+ except Exception as e:
+ logger.error(f"Error saving uploaded file '{fileName}': {e}")
+ raise
+
+ def createFile(self, name: str, mimeType: str, content: bytes, folderId=None):
+ """Create a new file record with content."""
+ try:
+ return self.interfaceDbComponent.createFile(name, mimeType, content, folderId=folderId)
+ except Exception as e:
+ logger.error(f"Error creating file '{name}': {e}")
+ raise
+
+ def createFileData(self, fileId: str, data: bytes):
+ """Write binary data for an existing file record."""
+ try:
+ return self.interfaceDbComponent.createFileData(fileId, data)
+ except Exception as e:
+ logger.error(f"Error creating file data for fileId '{fileId}': {e}")
+ raise
+
+ def updateFile(self, fileId: str, updateData: dict):
+ """Update file metadata (tags, fileName, fileSize, folderId, etc.)."""
+ try:
+ return self.interfaceDbComponent.updateFile(fileId, updateData)
+ except Exception as e:
+ logger.error(f"Error updating file '{fileId}': {e}")
+ raise
+
+ def updateFileData(self, fileId: str, data: bytes):
+ """Replace file binary content."""
+ try:
+ return self.interfaceDbComponent.updateFileData(fileId, data)
+ except Exception as e:
+ logger.error(f"Error updating file data for fileId '{fileId}': {e}")
+ raise
+
+ # ---- File-Manage operations (delegate to interfaceDbComponent) ----
+
+ def getFile(self, fileId: str):
+ """Get file metadata object by ID."""
+ try:
+ return self.interfaceDbComponent.getFile(fileId)
+ except Exception as e:
+ logger.error(f"Error getting file '{fileId}': {e}")
+ return None
+
+ def deleteFile(self, fileId: str):
+ """Delete a file by ID."""
+ try:
+ return self.interfaceDbComponent.deleteFile(fileId)
+ except Exception as e:
+ logger.error(f"Error deleting file '{fileId}': {e}")
+ raise
+
+ def copyFile(self, sourceFileId: str, newFileName=None):
+ """Copy a file, optionally with a new name."""
+ try:
+ return self.interfaceDbComponent.copyFile(sourceFileId, newFileName=newFileName)
+ except Exception as e:
+ logger.error(f"Error copying file '{sourceFileId}': {e}")
+ raise
+
+ def isTextMimeType(self, mimeType: str) -> bool:
+ """Check if a MIME type represents text content."""
+ try:
+ return self.interfaceDbComponent.isTextMimeType(mimeType)
+ except Exception as e:
+ logger.error(f"Error checking MIME type '{mimeType}': {e}")
+ return False
+
+ def getMimeType(self, fileName: str) -> str:
+ """Determine MIME type from file name."""
+ try:
+ return self.interfaceDbComponent.getMimeType(fileName)
+ except Exception as e:
+ logger.error(f"Error getting MIME type for '{fileName}': {e}")
+ return "application/octet-stream"
+
+ # ---- Folder operations (delegate to interfaceDbComponent) ----
+
+ def createFolder(self, name: str, parentId=None):
+ """Create a folder, optionally under a parent."""
+ try:
+ return self.interfaceDbComponent.createFolder(name, parentId=parentId)
+ except Exception as e:
+ logger.error(f"Error creating folder '{name}': {e}")
+ raise
+
+ def getOwnFolderTree(self):
+ """Get the user's folder tree."""
+ try:
+ return self.interfaceDbComponent.getOwnFolderTree()
+ except Exception as e:
+ logger.error(f"Error getting folder tree: {e}")
+ return None
+
+ def renameFolder(self, folderId: str, newName: str):
+ """Rename a folder."""
+ try:
+ return self.interfaceDbComponent.renameFolder(folderId, newName)
+ except Exception as e:
+ logger.error(f"Error renaming folder '{folderId}': {e}")
+ raise
+
+ # ---- Workflow-Listing operations (delegate to interfaceDbChat) ----
+
+ def getWorkflows(self, pagination=None):
+ """Get all workflows for the current context."""
+ try:
+ return self.interfaceDbChat.getWorkflows(pagination)
+ except Exception as e:
+ logger.error(f"Error getting workflows: {e}")
+ return []
+
+ def getMessages(self, workflowId: str, pagination=None):
+ """Get messages for a specific workflow."""
+ try:
+ return self.interfaceDbChat.getMessages(workflowId, pagination)
+ except Exception as e:
+ logger.error(f"Error getting messages for workflow '{workflowId}': {e}")
+ return []
+
def createWorkflow(self, workflowData: Dict[str, Any]):
"""Create a new workflow by delegating to the chat interface"""
try:
diff --git a/modules/serviceCenter/services/serviceClickup/__init__.py b/modules/serviceCenter/services/serviceClickup/__init__.py
index 6b3bb1f3..49f56ec0 100644
--- a/modules/serviceCenter/services/serviceClickup/__init__.py
+++ b/modules/serviceCenter/services/serviceClickup/__init__.py
@@ -2,6 +2,6 @@
# All rights reserved.
"""ClickUp service."""
-from .mainServiceClickup import ClickupService, clickup_authorization_header
+from .mainServiceClickup import ClickupService
-__all__ = ["ClickupService", "clickup_authorization_header"]
+__all__ = ["ClickupService"]
diff --git a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py
index df216810..d1ef51b3 100644
--- a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py
+++ b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
-def clickup_authorization_header(token: str) -> str:
+def _clickupAuthorizationHeader(token: str) -> str:
"""ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer."""
return clickupAuthorizationHeader(token)
diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
index a7e9a36a..5dbf16de 100644
--- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
+++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
@@ -31,7 +31,6 @@ class _ServicesAdapter:
self.mandateId = context.mandate_id
self.featureInstanceId = context.feature_instance_id
chat = get_service("chat")
- self.interfaceDbComponent = chat.interfaceDbComponent
self.interfaceDbChat = chat.interfaceDbChat
@property
@@ -56,7 +55,6 @@ class GenerationService:
"""Initialize with ServiceCenterContext and service resolver."""
self.services = _ServicesAdapter(context, get_service)
self._get_service = get_service
- self.interfaceDbComponent = self.services.interfaceDbComponent
self.interfaceDbChat = self.services.interfaceDbChat
def processActionResultDocuments(self, actionResult, action) -> List[Dict[str, Any]]:
@@ -289,10 +287,11 @@ class GenerationService:
logger.warning(f"Could not set workflow context on document: {str(e)}")
def _createDocument(self, fileName: str, mimeType: str, content: str, base64encoded: bool = True, messageId: str = None) -> Optional[ChatDocument]:
- """Create file and ChatDocument using interfaces without service indirection."""
+ """Create file and ChatDocument using chat service."""
try:
- if not self.interfaceDbComponent:
- logger.error("Component interface not available for document creation")
+ chat = self.services.chat
+ if not chat:
+ logger.error("Chat service not available for document creation")
return None
# Convert content to bytes
if base64encoded:
@@ -301,12 +300,12 @@ class GenerationService:
else:
content_bytes = content.encode('utf-8')
# Create file and store data
- file_item = self.interfaceDbComponent.createFile(
+ file_item = chat.createFile(
name=fileName,
mimeType=mimeType,
content=content_bytes
)
- self.interfaceDbComponent.createFileData(file_item.id, content_bytes)
+ chat.createFileData(file_item.id, content_bytes)
# Collect file info
file_info = self._getFileInfo(file_item.id)
if not file_info:
@@ -321,12 +320,6 @@ class GenerationService:
fileSize=file_info.get("size", 0),
mimeType=file_info.get("mimeType", mimeType)
)
- # Ensure document can access component interface later
- if hasattr(document, 'setComponentInterface') and self.interfaceDbComponent:
- try:
- document.setComponentInterface(self.interfaceDbComponent)
- except Exception:
- pass
return document
except Exception as e:
logger.error(f"Error creating document: {str(e)}")
@@ -334,9 +327,10 @@ class GenerationService:
def _getFileInfo(self, fileId: str) -> Optional[Dict[str, Any]]:
try:
- if not self.interfaceDbComponent:
+ chat = self.services.chat
+ if not chat:
return None
- file_item = self.interfaceDbComponent.getFile(fileId)
+ file_item = chat.getFile(fileId)
if file_item:
return {
"id": file_item.id,
diff --git a/modules/shared/systemComponentRegistry.py b/modules/shared/systemComponentRegistry.py
new file mode 100644
index 00000000..e4733a68
--- /dev/null
+++ b/modules/shared/systemComponentRegistry.py
@@ -0,0 +1,32 @@
+# Copyright (c) 2025 Patrick Motsch
+"""
+System-component lifecycle-hook registry (Layer L0 — shared).
+
+Higher-layer system components (e.g. workflowAutomation) register their
+lifecycle hooks here at boot time via ``app.py`` (Composition Root, L7).
+Interface modules read the registry generically — no upward imports needed.
+
+Supported events: ``onBootstrap``, ``onMandateDelete``, ``onInstanceCreate``.
+
+This is the same inversion pattern used by
+``serviceAgent/externalToolRegistry.py`` for agent tools.
+"""
+
+import logging
+from typing import Any, Callable, Dict, List
+
+logger = logging.getLogger(__name__)
+
+_hooks: Dict[str, List[Callable[..., Any]]] = {}
+
+
+def registerLifecycleHook(eventName: str, handler: Callable[..., Any]) -> None:
+ """Register a lifecycle handler for *eventName*."""
+ _hooks.setdefault(eventName, []).append(handler)
+ logger.info("Registered system-component lifecycle hook: %s -> %s",
+ eventName, getattr(handler, "__qualname__", repr(handler)))
+
+
+def getLifecycleHooks(eventName: str) -> List[Callable[..., Any]]:
+ """Return all registered handlers for *eventName* (may be empty)."""
+ return list(_hooks.get(eventName, []))
diff --git a/modules/workflowAutomation/engine/workflowArtifactVisibility.py b/modules/shared/workflowArtifactVisibility.py
similarity index 90%
rename from modules/workflowAutomation/engine/workflowArtifactVisibility.py
rename to modules/shared/workflowArtifactVisibility.py
index 0eb8d4bd..3431bee2 100644
--- a/modules/workflowAutomation/engine/workflowArtifactVisibility.py
+++ b/modules/shared/workflowArtifactVisibility.py
@@ -9,13 +9,13 @@ from typing import Any, Mapping, Optional
_WORKFLOW_INTERNAL_FILE_TAG = "_workflowInternal"
-def suppress_workflow_file_in_workspace_ui(meta: Optional[Mapping[str, Any]]) -> bool:
+def suppressWorkflowFileInWorkspaceUi(meta: Optional[Mapping[str, Any]]) -> bool:
"""True when a file row should not appear in user-facing file lists.
Used by Automation Workspace **and** ``/api/files/list`` (Meine Dateien).
Matches persisted JSON handovers from transient runs (``extracted_content_transient*``),
internal extract image files (``extract_media_*``), the ``_workflowInternal`` tag, and
- optional explicit flags.
+ optional explicit flags.
"""
if not isinstance(meta, Mapping):
return False
diff --git a/modules/shared/workflowState.py b/modules/shared/workflowState.py
index 069645b9..6a8680a3 100644
--- a/modules/shared/workflowState.py
+++ b/modules/shared/workflowState.py
@@ -32,7 +32,7 @@ def checkWorkflowStopped(services: Any) -> None:
try:
# Get the current workflow status from the database to avoid stale data
- currentWorkflow = services.interfaceDbChat.getWorkflow(workflow.id)
+ currentWorkflow = services.chat.getWorkflow(workflow.id)
if currentWorkflow and currentWorkflow.status == "stopped":
logger.info("Workflow stopped by user, aborting operation")
raise WorkflowStoppedException("Workflow was stopped by user")
diff --git a/modules/workflowAutomation/engine/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py
index 443de25d..99f7c2ed 100644
--- a/modules/workflowAutomation/engine/executionEngine.py
+++ b/modules/workflowAutomation/engine/executionEngine.py
@@ -34,8 +34,8 @@ from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
from modules.workflowAutomation.engine.runFileLogger import (
RunFileLogger,
- graphical_editor_run_file_logging_enabled,
- merge_run_context_with_ge_log_prefix,
+ workflowAutomationRunFileLoggingEnabled,
+ mergeRunContextWithWaLogPrefix,
)
from modules.workflowAutomation.engine.runEnvelope import normalize_run_envelope
@@ -383,7 +383,7 @@ async def _ge_log_node_finished(
exec_rec["output"] = (
_stripBinaryValues(output) if isinstance(output, dict) else {"value": _stripBinaryValues(output)}
)
- await file_logger.append_node_execution_line(exec_rec)
+ await file_logger.appendNodeExecutionLine(exec_rec)
ctx_rec: Dict[str, Any] = {
"timestamp": ts,
@@ -398,7 +398,7 @@ async def _ge_log_node_finished(
ctx_rec["loopIndex"] = loop_index
if loop_node_id is not None:
ctx_rec["loopNodeId"] = loop_node_id
- await file_logger.append_context_snapshot_line(ctx_rec)
+ await file_logger.appendContextSnapshotLine(ctx_rec)
async def _executeWithRetry(executor, node, context, maxRetries: int = 0, retryDelaySeconds: float = 1.0):
@@ -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[RunFileLogger] = None,
+ waFileLogger: 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)
@@ -553,7 +553,7 @@ async def _run_post_loop_done_nodes(
if _skId:
_updateStepLog(automation2_interface, _skId, "skipped")
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -586,7 +586,7 @@ async def _run_post_loop_done_nodes(
output=_dres if isinstance(_dres, dict) else {"value": _dres},
durationMs=_dDur, tokensUsed=_dTok, retryCount=_dRetry)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -603,7 +603,7 @@ async def _run_post_loop_done_nodes(
_updateStepLog(automation2_interface, _dStepId, "completed",
durationMs=int((time.time() - _dStart) * 1000))
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -619,7 +619,7 @@ async def _run_post_loop_done_nodes(
_updateStepLog(automation2_interface, _dStepId, "completed",
durationMs=int((time.time() - _dStart) * 1000))
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -636,7 +636,7 @@ async def _run_post_loop_done_nodes(
_updateStepLog(automation2_interface, _dStepId, "failed",
error="Subscription/Billing error", durationMs=_dFailDur)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -654,7 +654,7 @@ async def _run_post_loop_done_nodes(
_updateStepLog(automation2_interface, _dStepId, "failed",
error=str(_dex), durationMs=_dFailDur2)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -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[RunFileLogger] = None
+ waFileLogger: Optional[RunFileLogger] = None
nodeOutputs: Dict[str, Any] = dict(initialNodeOutputs or {})
if not runId and automation2_interface and workflowId and not is_resume:
run_context = {
@@ -805,8 +805,8 @@ 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 = RunFileLogger.bootstrap_new_run(
+ if runId and workflowAutomationRunFileLoggingEnabled():
+ waFileLogger = RunFileLogger.bootstrapNewRun(
automation2_interface,
runId,
run_context,
@@ -842,12 +842,12 @@ async def executeGraph(
_activeRunContexts[runId] = context
if (
- graphical_editor_run_file_logging_enabled()
+ workflowAutomationRunFileLoggingEnabled()
and automation2_interface
and runId
- and ge_file_logger is None
+ and waFileLogger is None
):
- ge_file_logger = RunFileLogger.ensure_attached(
+ waFileLogger = RunFileLogger.ensureAttached(
automation2_interface,
runId,
)
@@ -916,7 +916,7 @@ async def executeGraph(
output=result if isinstance(result, dict) else {"value": result},
durationMs=_rDur, retryCount=_rRetry)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -940,7 +940,7 @@ async def executeGraph(
_updateStepLog(automation2_interface, _rStepId, "completed",
durationMs=_rPauseDur)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -964,7 +964,7 @@ async def executeGraph(
_updateStepLog(automation2_interface, _rStepId, "completed",
durationMs=_rEmailDur)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -984,7 +984,7 @@ async def executeGraph(
_updateStepLog(automation2_interface, _rStepId, "failed",
error="Subscription/Billing error", durationMs=_rFailDurSb)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1005,7 +1005,7 @@ async def executeGraph(
_updateStepLog(automation2_interface, _rStepId, "failed",
error=str(ex), durationMs=_rFailDurEx)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1049,7 +1049,7 @@ async def executeGraph(
automation2_interface=automation2_interface,
runId=runId,
processed_in_loop=processed_in_loop,
- ge_file_logger=ge_file_logger,
+ waFileLogger=waFileLogger,
)
for i, node in enumerate(ordered):
@@ -1088,7 +1088,7 @@ async def executeGraph(
if _skipStepId:
_updateStepLog(automation2_interface, _skipStepId, "skipped")
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1206,7 +1206,7 @@ async def executeGraph(
output=bres if isinstance(bres, dict) else {"value": bres},
durationMs=_bDur, retryCount=_bRetry)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=_activeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1230,7 +1230,7 @@ async def executeGraph(
_updateStepLog(automation2_interface, _bStepId, "completed",
durationMs=_bHd)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=_activeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1256,7 +1256,7 @@ async def executeGraph(
_updateStepLog(automation2_interface, _bStepId, "completed",
durationMs=_bEd)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=_activeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1277,7 +1277,7 @@ async def executeGraph(
_updateStepLog(automation2_interface, _bStepId, "failed",
error="Subscription/Billing error", durationMs=_bSb)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=_activeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1299,7 +1299,7 @@ async def executeGraph(
_updateStepLog(automation2_interface, _bStepId, "failed",
error=str(ex), durationMs=_bFail)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=_activeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1393,7 +1393,7 @@ async def executeGraph(
automation2_interface=automation2_interface,
runId=runId,
processed_in_loop=processed_in_loop,
- ge_file_logger=ge_file_logger,
+ waFileLogger=waFileLogger,
)
_loopDurMs = int((time.time() - _stepStartMs) * 1000)
@@ -1407,7 +1407,7 @@ async def executeGraph(
output=_loopStepOut,
durationMs=_loopDurMs)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1441,7 +1441,7 @@ async def executeGraph(
output=result if isinstance(result, dict) else {"value": result},
durationMs=_mergeDurMs, tokensUsed=_mergeTok, retryCount=retryCount)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1471,7 +1471,7 @@ async def executeGraph(
output=result if isinstance(result, dict) else {"value": result},
durationMs=_durMs, tokensUsed=_tokens, retryCount=retryCount)
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1500,7 +1500,7 @@ async def executeGraph(
if _ge_in is None:
_ge_in = locals().get("_loopInputSnap") or {}
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1528,7 +1528,7 @@ async def executeGraph(
if _ge_email_in is None:
_ge_email_in = locals().get("_loopInputSnap") or {}
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
@@ -1564,7 +1564,7 @@ async def executeGraph(
}
if automation2_interface and e.runId:
prev_ctx = dict((automation2_interface.getRun(e.runId) or {}).get("context") or {})
- run_ctx = merge_run_context_with_ge_log_prefix(prev_ctx, run_ctx)
+ run_ctx = mergeRunContextWithWaLogPrefix(prev_ctx, run_ctx)
automation2_interface.updateRun(
e.runId,
status="paused",
@@ -1589,7 +1589,7 @@ async def executeGraph(
if _ge_fail_in is None:
_ge_fail_in = locals().get("_loopInputSnap") or {}
await _ge_log_node_finished(
- ge_file_logger,
+ waFileLogger,
run_id=runId,
node_outputs=nodeOutputs,
run_envelope=context.get("runEnvelope"),
diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
index 799d1606..82c0cbe1 100644
--- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
+++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
@@ -210,10 +210,10 @@ def _resolveConnectionIdToReference(chatService, connectionId: str, services=Non
return f"connection:{authority}:{username}"
except Exception as e:
logger.debug("_resolveConnectionIdToReference chatService: %s", e)
- app = getattr(services, "interfaceDbApp", None) if services else None
- if app and hasattr(app, "getUserConnectionById"):
+ chatSvc = getattr(services, "chat", None) if services else None
+ if chatSvc and hasattr(chatSvc, "getUserConnectionById"):
try:
- conn = app.getUserConnectionById(str(connectionId))
+ conn = chatSvc.getUserConnectionById(str(connectionId))
if conn:
authority = getattr(conn, "authority", None)
if hasattr(authority, "value"):
@@ -542,8 +542,7 @@ class ActionNodeExecutor:
resolvedParams[pname] = _wired
# 3. Resolve connectionReference
- chatService = getattr(self.services, "chat", None)
- _resolveConnectionParam(resolvedParams, chatService, self.services)
+ _resolveConnectionParam(resolvedParams, self.services.chat, self.services)
# 3b. Optional graph-level injections declared on the node definition.
# - injectUpstreamPayload: True → ``_upstreamPayload`` (port 0 source output, transit-unwrapped)
@@ -580,12 +579,10 @@ class ActionNodeExecutor:
# 6. Create progress parent so nested actions have a hierarchy
nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(time.time())}"
- chatService = getattr(self.services, "chat", None)
- if chatService:
- try:
- chatService.progressLogStart(nodeOperationId, methodName.capitalize(), actionName, f"Node {nodeId}")
- except Exception:
- pass
+ try:
+ self.services.chat.progressLogStart(nodeOperationId, methodName.capitalize(), actionName, f"Node {nodeId}")
+ except Exception:
+ pass
resolvedParams["parentOperationId"] = nodeOperationId
# 9. Execute action
@@ -632,26 +629,7 @@ class ActionNodeExecutor:
rawBytes = coerceDocumentDataToBytes(rawData)
if isinstance(dumped, dict) and rawBytes:
try:
- from modules.interfaces.interfaceDbManagement import getInterface as _getMgmtInterface
- from modules.interfaces.interfaceDbApp import getInterface as _getAppInterface
- from modules.security.rootAccess import getRootUser
- _userId = context.get("userId")
- _mandateId = context.get("mandateId")
- _instanceId = context.get("instanceId")
- _owner = None
- if _userId:
- try:
- _umap = _getAppInterface(getRootUser()).getUsersByIds([str(_userId)])
- _owner = _umap.get(str(_userId))
- except Exception as _ue:
- logger.warning("Could not resolve workflow user for file persistence: %s", _ue)
- if _owner is None:
- _owner = getRootUser()
- logger.debug(
- "Persisting workflow document as root user (no resolved owner userId=%r)",
- _userId,
- )
- _mgmt = _getMgmtInterface(_owner, mandateId=_mandateId, featureInstanceId=_instanceId)
+ _mgmt = self.services.interfaceDbComponent
_docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin"
_mimeType = dumped.get("mimeType") or "application/octet-stream"
_fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes, folderId=persist_folder_id)
diff --git a/modules/workflowAutomation/engine/executors/inputExecutor.py b/modules/workflowAutomation/engine/executors/inputExecutor.py
index 39efcfe6..926dd3a8 100644
--- a/modules/workflowAutomation/engine/executors/inputExecutor.py
+++ b/modules/workflowAutomation/engine/executors/inputExecutor.py
@@ -47,9 +47,9 @@ class InputExecutor:
)
taskId = task.get("id")
- from modules.workflowAutomation.engine.runFileLogger import merge_persisted_run_context
+ from modules.workflowAutomation.engine.runFileLogger import mergePersistedRunContext
- _pause_ctx = merge_persisted_run_context(
+ _pause_ctx = mergePersistedRunContext(
self.automation2,
runId,
{
diff --git a/modules/workflowAutomation/engine/runFileLogger.py b/modules/workflowAutomation/engine/runFileLogger.py
index 07600317..af57c275 100644
--- a/modules/workflowAutomation/engine/runFileLogger.py
+++ b/modules/workflowAutomation/engine/runFileLogger.py
@@ -1,5 +1,5 @@
# Copyright (c) 2025 Patrick Motsch
-"""Per-run NDJSON logs for persisted Automation2 / graphical-editor runs."""
+"""Per-run NDJSON logs for persisted workflow-automation runs."""
from __future__ import annotations
@@ -16,40 +16,40 @@ from modules.shared.debugLogger import ensureDir, resolve_app_log_dir
logger = logging.getLogger(__name__)
-RUN_FILE_LOG_RELATIVE_ROOT = "graphical_editor_runs"
-CONTEXT_KEY = "_geRunFileLogRelativeDir"
+RUN_FILE_LOG_RELATIVE_ROOT = "workflow_automation_runs"
+CONTEXT_KEY = "_waRunFileLogRelativeDir"
EXECUTION_FILENAME = "node_execution.ndjson"
CONTEXT_SNAPSHOT_FILENAME = "workflow_context.ndjson"
-def graphical_editor_run_file_logging_enabled() -> bool:
+def workflowAutomationRunFileLoggingEnabled() -> bool:
"""True when NDJSON files should be written for each persisted run."""
- raw = APP_CONFIG.get("APP_GRAPHICAL_EDITOR_RUN_FILE_LOGGING", False)
+ raw = APP_CONFIG.get("APP_WORKFLOW_AUTOMATION_RUN_FILE_LOGGING") or APP_CONFIG.get("APP_GRAPHICAL_EDITOR_RUN_FILE_LOGGING", False)
if isinstance(raw, bool):
return raw
s = str(raw).strip().lower()
return s in ("1", "true", "yes", "on")
-def merge_run_context_with_ge_log_prefix(
- base_context: Optional[Dict[str, Any]],
+def mergeRunContextWithWaLogPrefix(
+ baseContext: Optional[Dict[str, Any]],
incoming: Dict[str, Any],
) -> Dict[str, Any]:
- """Copy ``CONTEXT_KEY`` from *base_context* onto *incoming* if present (pause paths)."""
+ """Copy ``CONTEXT_KEY`` from *baseContext* onto *incoming* if present (pause paths)."""
out = dict(incoming or {})
- prev = (base_context or {}).get(CONTEXT_KEY)
+ prev = (baseContext or {}).get(CONTEXT_KEY)
if prev is not None:
out[CONTEXT_KEY] = prev
return out
-def merge_persisted_run_context(
- automation2_interface: Any,
- run_id: str,
+def mergePersistedRunContext(
+ workflowAutomationInterface: Any,
+ runId: str,
replacement: Dict[str, Any],
) -> Dict[str, Any]:
- """``{**db_context, **replacement}`` so *_geRunFileLogRelativeDir* and other keys survive pause updates."""
- prev = dict((automation2_interface.getRun(run_id) or {}).get("context") or {})
+ """``{**db_context, **replacement}`` so *_waRunFileLogRelativeDir* and other keys survive pause updates."""
+ prev = dict((workflowAutomationInterface.getRun(runId) or {}).get("context") or {})
return {**prev, **(replacement or {})}
@@ -58,65 +58,65 @@ class RunFileLogger:
__slots__ = ("_exec_path", "_ctx_path", "_lock", "_run_id")
- def __init__(self, run_id: str, absolute_run_dir: str) -> None:
- self._run_id = run_id
- ensureDir(absolute_run_dir)
- self._exec_path = os.path.join(absolute_run_dir, EXECUTION_FILENAME)
- self._ctx_path = os.path.join(absolute_run_dir, CONTEXT_SNAPSHOT_FILENAME)
+ def __init__(self, runId: str, absoluteRunDir: str) -> None:
+ self._run_id = runId
+ ensureDir(absoluteRunDir)
+ self._exec_path = os.path.join(absoluteRunDir, EXECUTION_FILENAME)
+ self._ctx_path = os.path.join(absoluteRunDir, CONTEXT_SNAPSHOT_FILENAME)
self._lock = asyncio.Lock()
@property
- def run_id(self) -> str:
+ def runId(self) -> str:
return self._run_id
@staticmethod
- def fresh_run_subdirectory_name(run_id: str) -> str:
+ def freshRunSubdirectoryName(runId: str) -> str:
ts = datetime.now(timezone.utc).strftime("%Y_%m_%d_%H_%M_%S")
- return f"{ts}__{run_id}"
+ return f"{ts}__{runId}"
@staticmethod
- def relative_run_path(subdir_name: str) -> str:
+ def relativeRunPath(subdirName: str) -> str:
"""Path relative to ``APP_LOGGING_LOG_DIR`` (POSIX-style segments)."""
- return "/".join((RUN_FILE_LOG_RELATIVE_ROOT, subdir_name))
+ return "/".join((RUN_FILE_LOG_RELATIVE_ROOT, subdirName))
@classmethod
- def bootstrap_new_run(cls, automation2_interface: Any, run_id: str, run_context: Dict[str, Any]) -> RunFileLogger | None:
+ def bootstrapNewRun(cls, workflowAutomationInterface: Any, runId: str, runContext: Dict[str, Any]) -> RunFileLogger | None:
"""Create filesystem folder + persist CONTEXT_KEY via ``updateRun``."""
- if not graphical_editor_run_file_logging_enabled():
+ if not workflowAutomationRunFileLoggingEnabled():
return None
- if not automation2_interface or not run_id:
+ if not workflowAutomationInterface or not runId:
return None
- subdir = cls.fresh_run_subdirectory_name(run_id)
- rel = cls.relative_run_path(subdir)
+ subdir = cls.freshRunSubdirectoryName(runId)
+ rel = cls.relativeRunPath(subdir)
base = resolve_app_log_dir()
absolute = os.path.join(base, RUN_FILE_LOG_RELATIVE_ROOT, subdir)
- merged = dict(run_context or {})
+ merged = dict(runContext or {})
merged[CONTEXT_KEY] = rel
try:
- automation2_interface.updateRun(run_id, context=merged)
+ workflowAutomationInterface.updateRun(runId, context=merged)
except Exception as ex:
- logger.warning("GeRunFileLog: could not persist log dir on run=%s: %s", run_id, ex)
+ logger.warning("WaRunFileLog: could not persist log dir on run=%s: %s", runId, ex)
return None
logger.info(
- "GeRunFileLog: created run folder %s (run=%s)",
+ "WaRunFileLog: created run folder %s (run=%s)",
absolute,
- run_id,
+ runId,
)
- return cls(run_id, absolute)
+ return cls(runId, absolute)
@classmethod
- def open_from_run_record(cls, automation2_interface: Any, run_id: str) -> RunFileLogger | None:
+ def openFromRunRecord(cls, workflowAutomationInterface: Any, runId: str) -> RunFileLogger | None:
"""Open logger for an existing run using CONTEXT_KEY from DB."""
- if not graphical_editor_run_file_logging_enabled():
+ if not workflowAutomationRunFileLoggingEnabled():
return None
- if not automation2_interface or not run_id:
+ if not workflowAutomationInterface or not runId:
return None
try:
- run = automation2_interface.getRun(run_id) or {}
+ run = workflowAutomationInterface.getRun(runId) or {}
except Exception as ex:
- logger.debug("GeRunFileLog: getRun failed run=%s: %s", run_id, ex)
+ logger.debug("WaRunFileLog: getRun failed run=%s: %s", runId, ex)
return None
rel = (run.get("context") or {}).get(CONTEXT_KEY)
if not rel or not isinstance(rel, str):
@@ -126,21 +126,21 @@ class RunFileLogger:
cand = os.path.realpath(os.path.join(base_norm, *rel.replace("\\", "/").split("/")))
if cand != allowed_root and not cand.startswith(allowed_root + os.sep):
logger.warning(
- "GeRunFileLog: path outside log root denied for run=%s rel=%s",
- run_id,
+ "WaRunFileLog: path outside log root denied for run=%s rel=%s",
+ runId,
rel,
)
return None
absolute = cand
- return cls(run_id, absolute)
+ return cls(runId, absolute)
@classmethod
- def find_existing_absolute_dir(cls, run_id: str) -> Optional[str]:
+ def findExistingAbsoluteDir(cls, runId: str) -> Optional[str]:
"""If a folder named ``*{timestamp}__{run_id}`` exists under the log root, return its absolute path."""
root = os.path.realpath(os.path.join(resolve_app_log_dir(), RUN_FILE_LOG_RELATIVE_ROOT))
if not os.path.isdir(root):
return None
- suffix = f"__{run_id}"
+ suffix = f"__{runId}"
try:
names = sorted((n for n in os.listdir(root) if n.endswith(suffix)), reverse=True)
except OSError:
@@ -154,62 +154,62 @@ class RunFileLogger:
return cand if os.path.isdir(cand) else None
@classmethod
- 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)
+ def ensureAttached(cls, workflowAutomationInterface: Any, runId: str) -> RunFileLogger | None:
+ """Open logger from DB, or reattach an on-disk folder for *runId*, or create a new one."""
+ opened = cls.openFromRunRecord(workflowAutomationInterface, runId)
if opened is not None:
return opened
- if not graphical_editor_run_file_logging_enabled():
+ if not workflowAutomationRunFileLoggingEnabled():
return None
- if not automation2_interface or not run_id:
+ if not workflowAutomationInterface or not runId:
return None
try:
- run = automation2_interface.getRun(run_id) or {}
+ run = workflowAutomationInterface.getRun(runId) or {}
except Exception as ex:
- logger.debug("GeRunFileLog: ensure getRun failed run=%s: %s", run_id, ex)
+ logger.debug("WaRunFileLog: ensure getRun failed run=%s: %s", runId, ex)
return None
prev_ctx = dict(run.get("context") or {})
- existing_abs = cls.find_existing_absolute_dir(run_id)
+ existing_abs = cls.findExistingAbsoluteDir(runId)
if existing_abs:
base_norm = os.path.realpath(resolve_app_log_dir())
rel = os.path.relpath(existing_abs, base_norm).replace(os.sep, "/")
merged = {**prev_ctx, CONTEXT_KEY: rel}
try:
- automation2_interface.updateRun(run_id, context=merged)
+ workflowAutomationInterface.updateRun(runId, context=merged)
except Exception as ex:
- logger.warning("GeRunFileLog: reattach persist failed run=%s: %s", run_id, ex)
+ logger.warning("WaRunFileLog: reattach persist failed run=%s: %s", runId, ex)
return None
- logger.info("GeRunFileLog: reattached existing folder for run=%s -> %s", run_id, existing_abs)
- return cls(run_id, existing_abs)
+ logger.info("WaRunFileLog: reattached existing folder for run=%s -> %s", runId, existing_abs)
+ return cls(runId, existing_abs)
- subdir = cls.fresh_run_subdirectory_name(run_id)
- rel = cls.relative_run_path(subdir)
+ subdir = cls.freshRunSubdirectoryName(runId)
+ rel = cls.relativeRunPath(subdir)
base = resolve_app_log_dir()
absolute = os.path.join(base, RUN_FILE_LOG_RELATIVE_ROOT, subdir)
merged = {**prev_ctx, CONTEXT_KEY: rel}
try:
- automation2_interface.updateRun(run_id, context=merged)
+ workflowAutomationInterface.updateRun(runId, context=merged)
except Exception as ex:
- logger.warning("GeRunFileLog: ensure new folder persist failed run=%s: %s", run_id, ex)
+ logger.warning("WaRunFileLog: ensure new folder persist failed run=%s: %s", runId, ex)
return None
- logger.info("GeRunFileLog: created late attach folder %s (run=%s)", absolute, run_id)
- return cls(run_id, absolute)
+ logger.info("WaRunFileLog: created late attach folder %s (run=%s)", absolute, runId)
+ return cls(runId, absolute)
- async def append_node_execution_line(self, record: Dict[str, Any]) -> None:
+ async def appendNodeExecutionLine(self, record: Dict[str, Any]) -> None:
line = json.dumps(record, ensure_ascii=False, default=str)
async with self._lock:
try:
with open(self._exec_path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception as ex:
- logger.warning("GeRunFileLog: append execution failed run=%s: %s", self._run_id, ex)
+ logger.warning("WaRunFileLog: append execution failed run=%s: %s", self._run_id, ex)
- async def append_context_snapshot_line(self, record: Dict[str, Any]) -> None:
+ async def appendContextSnapshotLine(self, record: Dict[str, Any]) -> None:
line = json.dumps(record, ensure_ascii=False, default=str)
async with self._lock:
try:
with open(self._ctx_path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception as ex:
- logger.warning("GeRunFileLog: append context snapshot failed run=%s: %s", self._run_id, ex)
+ logger.warning("WaRunFileLog: append context snapshot failed run=%s: %s", self._run_id, ex)
diff --git a/modules/workflowAutomation/helpers.py b/modules/workflowAutomation/helpers.py
index 21471e6e..ddbde49e 100644
--- a/modules/workflowAutomation/helpers.py
+++ b/modules/workflowAutomation/helpers.py
@@ -22,7 +22,7 @@ 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,
+ WORKFLOW_AUTOMATION_DATABASE,
)
from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
from modules.shared.configuration import APP_CONFIG
@@ -38,7 +38,7 @@ def _getWorkflowAutomationDb() -> DatabaseConnector:
"""Get a DatabaseConnector for the WorkflowAutomation (graphicaleditor) DB."""
return DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
- dbDatabase=GRAPHICAL_EDITOR_DATABASE,
+ dbDatabase=WORKFLOW_AUTOMATION_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)),
diff --git a/modules/workflowAutomation/mainWorkflowAutomation.py b/modules/workflowAutomation/mainWorkflowAutomation.py
index 20c1d4fb..e3a38d84 100644
--- a/modules/workflowAutomation/mainWorkflowAutomation.py
+++ b/modules/workflowAutomation/mainWorkflowAutomation.py
@@ -39,12 +39,12 @@ def _getWorkflowAutomationServices(
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
workflow=None,
-) -> "_WorkflowAutomationServiceHub":
+):
"""
- Get a service hub for WorkflowAutomation using the service center.
+ Get a ServicesBag for WorkflowAutomation using the service center.
Used for methodDiscovery (I/O nodes) and execution (ActionExecutor).
"""
- from modules.serviceCenter import getService
+ from modules.serviceCenter import getService, ServicesBag
from modules.serviceCenter.context import ServiceCenterContext
_workflow = workflow
@@ -61,55 +61,7 @@ def _getWorkflowAutomationServices(
feature_instance_id=featureInstanceId,
workflow=_workflow,
)
-
- hub = _WorkflowAutomationServiceHub()
- hub.user = user
- hub.mandateId = mandateId
- hub.featureInstanceId = featureInstanceId
- hub._service_context = ctx
- hub.workflow = _workflow
- hub.featureCode = COMPONENT_CODE
-
- for spec in REQUIRED_SERVICES:
- key = spec["serviceKey"]
- try:
- svc = getService(key, ctx)
- setattr(hub, key, svc)
- except Exception as e:
- logger.warning(f"Could not resolve service '{key}' for workflowAutomation: {e}")
- setattr(hub, key, None)
-
- if hub.chat:
- hub.interfaceDbApp = getattr(hub.chat, "interfaceDbApp", None)
- hub.interfaceDbComponent = getattr(hub.chat, "interfaceDbComponent", None)
- hub.interfaceDbChat = getattr(hub.chat, "interfaceDbChat", None)
- hub.rbac = getattr(hub.interfaceDbApp, "rbac", None) if getattr(hub, "interfaceDbApp", None) else None
-
- return hub
-
-
-
-
-class _WorkflowAutomationServiceHub:
- """Lightweight hub for WorkflowAutomation (methodDiscovery, execution)."""
-
- user = None
- mandateId = None
- featureInstanceId = None
- _service_context = None
- workflow = None
- featureCode = COMPONENT_CODE
- interfaceDbApp = None
- interfaceDbComponent = None
- interfaceDbChat = None
- rbac = None
- chat = None
- ai = None
- utils = None
- extraction = None
- sharepoint = None
- clickup = None
- generation = None
+ return ServicesBag(ctx, lambda key: getService(key, ctx))
# ---------------------------------------------------------------------------
@@ -119,7 +71,7 @@ class _WorkflowAutomationServiceHub:
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all AutoWorkflow data for this mandate."""
from modules.datamodels.datamodelWorkflowAutomation import (
- GRAPHICAL_EDITOR_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
+ WORKFLOW_AUTOMATION_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
)
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
@@ -127,7 +79,7 @@ def onMandateDelete(mandateId: str, instances: list) -> None:
try:
waDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
- dbDatabase=GRAPHICAL_EDITOR_DATABASE,
+ dbDatabase=WORKFLOW_AUTOMATION_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)),
@@ -245,14 +197,14 @@ def onBootstrap() -> None:
_migrateRbacNamespace()
_registerAgentTools()
- from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow
+ from modules.datamodels.datamodelWorkflowAutomation import WORKFLOW_AUTOMATION_DATABASE, AutoWorkflow
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
try:
waDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
- dbDatabase=GRAPHICAL_EDITOR_DATABASE,
+ dbDatabase=WORKFLOW_AUTOMATION_DATABASE,
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
)
diff --git a/modules/workflowAutomation/scheduler/mainScheduler.py b/modules/workflowAutomation/scheduler/mainScheduler.py
index 2f45932e..ec368480 100644
--- a/modules/workflowAutomation/scheduler/mainScheduler.py
+++ b/modules/workflowAutomation/scheduler/mainScheduler.py
@@ -209,7 +209,7 @@ class WorkflowScheduler:
from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices
from modules.workflowAutomation.engine.executionEngine import executeGraph
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
- from modules.workflowAutomation.editor.entryPoints import find_invocation
+ from modules.nodeCatalog.entryPoints import findInvocation
from modules.workflowAutomation.engine.runEnvelope import default_run_envelope, normalize_run_envelope
iface = _getWorkflowAutomationInterface(eventUser, mandateId, instanceId)
@@ -221,7 +221,7 @@ class WorkflowScheduler:
logger.info("WorkflowScheduler: workflow %s inactive, skipping", workflowId)
return
- inv = find_invocation(wf, entryPointId)
+ inv = findInvocation(wf, entryPointId)
if inv and (inv.get("kind") != "schedule" or not inv.get("enabled", True)):
logger.info("WorkflowScheduler: entry point %s disabled for workflow %s", entryPointId, workflowId)
return
diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py
index 04046f39..e3cc10f0 100644
--- a/modules/workflows/methods/methodAi/actions/process.py
+++ b/modules/workflows/methods/methodAi/actions/process.py
@@ -40,7 +40,7 @@ def _action_docs_to_content_parts(services, docs: List[Any]) -> List[ContentPart
from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy
all_parts = []
- extraction = getattr(services, "extraction", None)
+ extraction = services.extraction
if not extraction:
logger.warning("ai.process: No extraction service - cannot extract from inline documents")
return []
@@ -80,25 +80,24 @@ def _resolve_file_refs_to_content_parts(services, fileIdRefs) -> List[ContentPar
via ``getChatDocumentsFromDocumentList`` instead."""
from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy
- mgmt = getattr(services, 'interfaceDbComponent', None)
- extraction = getattr(services, 'extraction', None)
- if not mgmt or not extraction:
- logger.warning("_resolve_file_refs_to_content_parts: missing interfaceDbComponent or extraction service")
+ extraction = services.extraction
+ if not extraction:
+ logger.warning("_resolve_file_refs_to_content_parts: missing extraction service")
return []
allParts: List[ContentPart] = []
opts = ExtractionOptions(prompt="", mergeStrategy=MergeStrategy())
for ref in fileIdRefs:
fileId = ref.documentId
- fileMeta = mgmt.getFile(fileId)
+ fileMeta = services.chat.getFile(fileId)
if not fileMeta:
logger.warning("_resolve_file_refs_to_content_parts: file %s not found "
"(lookup scope: mandate=%s, featureInstanceId=%s, userId=%s)",
- fileId, getattr(mgmt, "mandateId", "?"),
- getattr(mgmt, "featureInstanceId", "?"),
- getattr(mgmt, "userId", "?"))
+ fileId, getattr(services, "mandateId", "?"),
+ getattr(services, "featureInstanceId", "?"),
+ getattr(services, "userId", "?"))
continue
- fileData = mgmt.getFileData(fileId)
+ fileData = services.chat.getFileData(fileId)
if not fileData:
logger.warning(f"_resolve_file_refs_to_content_parts: no data for file {fileId}")
continue
@@ -265,7 +264,7 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
try:
documents = self.services.chat.getChatDocumentsFromDocumentList(documentList)
simpleParts = _action_docs_to_content_parts(self.services, [
- {"documentData": self.services.interfaceDbComponent.getFileData(doc.fileId),
+ {"documentData": self.services.chat.getFileData(doc.fileId),
"documentName": getattr(doc, 'fileName', ''),
"mimeType": getattr(doc, 'mimeType', 'application/octet-stream')}
for doc in documents if hasattr(doc, 'fileId') and doc.fileId
diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py
index 778faf11..b020cff4 100644
--- a/modules/workflows/methods/methodAi/actions/webResearch.py
+++ b/modules/workflows/methods/methodAi/actions/webResearch.py
@@ -44,8 +44,6 @@ def _build_research_prompt(parameters: Dict[str, Any]) -> str:
async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
- from modules.serviceCenter import ServiceCenterContext, getService, can_access_service
-
operationId = None
try:
prompt = _build_research_prompt(parameters)
@@ -53,25 +51,10 @@ async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
return ActionResult.isFailure(error="Research prompt is required")
# RBAC: Check service-level permission
- rbac = getattr(self.services, "rbac", None)
- if rbac and not can_access_service(
- self.services.user,
- rbac,
- "web",
- mandate_id=getattr(self.services, "mandateId", None),
- feature_instance_id=getattr(self.services, "featureInstanceId", None),
- ):
+ if hasattr(self.services, "canAccessService") and not self.services.canAccessService("web"):
return ActionResult.isFailure(error="Permission denied: Web research service")
- # Build context for service center
- context = ServiceCenterContext(
- user=self.services.user,
- mandate_id=getattr(self.services, "mandateId", None),
- feature_instance_id=getattr(self.services, "featureInstanceId", None),
- workflow_id=self.services.workflow.id if self.services.workflow else None,
- workflow=self.services.workflow,
- )
- web_service = getService("web", context)
+ web_service = self.services.getService("web")
# Init progress logger
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py
index abc7b9c0..d9a941c5 100644
--- a/modules/workflows/methods/methodBase.py
+++ b/modules/workflows/methods/methodBase.py
@@ -133,7 +133,7 @@ class MethodBase:
return False
# Get current user from services.user (not from chat service)
- currentUser = getattr(self.services, 'user', None)
+ currentUser = self.services.user
if not currentUser:
self.logger.warning(f"No current user found (services.user is None). Action {actionId} will be denied.")
return False
@@ -141,8 +141,8 @@ class MethodBase:
# RBAC-Check: RESOURCE context, item = actionId
# mandateId/featureInstanceId from services context needed to resolve user roles
try:
- mandateId = getattr(self.services, 'mandateId', None)
- featureInstanceId = getattr(self.services, 'featureInstanceId', None)
+ mandateId = self.services.mandateId
+ featureInstanceId = self.services.featureInstanceId
permissions = self.services.rbac.getUserPermissions(
user=currentUser,
context=AccessRuleContext.RESOURCE,
diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py
index 2c1a2f9c..e1869be3 100644
--- a/modules/workflows/methods/methodContext/actions/extractContent.py
+++ b/modules/workflows/methods/methodContext/actions/extractContent.py
@@ -1177,6 +1177,7 @@ def _persist_extracted_image_parts(
*,
name_stem: str,
run_context: Optional[Dict[str, Any]],
+ services=None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Decode base64 image parts, persist bytes, replace with ``embeddedImageFileId``; return artifacts meta."""
artifacts: List[Dict[str, Any]] = []
@@ -1193,27 +1194,19 @@ def _persist_extracted_image_parts(
)
return content_extracted_serial, artifacts
- try:
+ if services and hasattr(services, "interfaceDbComponent"):
+ mgmt = services.interfaceDbComponent
+ else:
from modules.interfaces.interfaceDbManagement import getInterface as _get_mgmt
- from modules.interfaces.interfaceDbApp import getInterface as _get_app
from modules.security.rootAccess import getRootUser
- except Exception as exc:
- logger.warning("extractContent image persist: import failed: %s", exc)
- return content_extracted_serial, artifacts
-
- owner = getRootUser()
- uid = run_context.get("userId")
- if uid:
try:
- umap = _get_app(getRootUser()).getUsersByIds([str(uid)])
- owner = umap.get(str(uid)) or owner
- except Exception:
- pass
+ mgmt = _get_mgmt(getRootUser(), mandateId=str(mandate_id), featureInstanceId=str(instance_id))
+ except Exception as exc:
+ logger.warning("extractContent image persist: mgmt interface failed: %s", exc)
+ return content_extracted_serial, artifacts
- try:
- mgmt = _get_mgmt(owner, mandateId=str(mandate_id), featureInstanceId=str(instance_id))
- except Exception as exc:
- logger.warning("extractContent image persist: mgmt interface failed: %s", exc)
+ if not mgmt:
+ logger.warning("extractContent image persist: no interfaceDbComponent available")
return content_extracted_serial, artifacts
stem = re.sub(r"[^\w\-]+", "_", name_stem).strip("_") or "extract"
@@ -1826,6 +1819,7 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
content_extracted_serial,
name_stem=stem,
run_context=run_ctx if isinstance(run_ctx, dict) else None,
+ services=self.services,
)
presentation = build_presentation_for_serial_extractions(content_extracted_serial, file_names, pres_cfg)
diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py
index bb778c8f..973f62d0 100644
--- a/modules/workflows/methods/methodFile/actions/create.py
+++ b/modules/workflows/methods/methodFile/actions/create.py
@@ -58,22 +58,9 @@ def _persistDocumentsToUserFiles(
) -> None:
"""Persist file.create output documents to user's file storage (like upload).
Adds fileId to each document's validationMetadata for download links in UI."""
- mgmt = getattr(services, "interfaceDbComponent", None)
- if not mgmt:
- try:
- import modules.interfaces.interfaceDbManagement as iface
- user = getattr(services, "user", None)
- if not user:
- return
- mgmt = iface.getInterface(
- user,
- mandateId=getattr(services, "mandateId", None) or "",
- featureInstanceId=getattr(services, "featureInstanceId", None) or "",
- )
- except Exception as e:
- logger.warning("file.create: could not get management interface for persistence: %s", e)
- return
- if not mgmt:
+ chat = getattr(services, "chat", None)
+ if not chat:
+ logger.warning("file.create: chat service not available for persistence")
return
for doc in action_documents:
try:
@@ -97,8 +84,8 @@ def _persistDocumentsToUserFiles(
or doc.get("mimeType")
or "application/octet-stream"
)
- file_item = mgmt.createFile(doc_name, mime, content, folderId=folder_id)
- mgmt.createFileData(file_item.id, content)
+ file_item = chat.createFile(doc_name, mime, content, folderId=folder_id)
+ chat.createFileData(file_item.id, content)
meta = getattr(doc, "validationMetadata", None) or doc.get("validationMetadata") or {}
if isinstance(meta, dict):
meta["fileId"] = file_item.id
@@ -118,23 +105,11 @@ def _sanitize_output_stem(title: str) -> str:
def _get_management_interface(services) -> Optional[Any]:
- mgmt = getattr(services, "interfaceDbComponent", None)
- if mgmt:
- return mgmt
- try:
- import modules.interfaces.interfaceDbManagement as iface
-
- user = getattr(services, "user", None)
- if not user:
- return None
- return iface.getInterface(
- user,
- mandateId=getattr(services, "mandateId", None) or "",
- featureInstanceId=getattr(services, "featureInstanceId", None) or "",
- )
- except Exception as e:
- logger.warning("file.create: could not get management interface: %s", e)
- return None
+ """Get chat service for file operations."""
+ chat = getattr(services, "chat", None)
+ if chat:
+ return chat
+ return None
def _load_image_bytes_from_action_doc(doc: dict, services) -> Optional[bytes]:
diff --git a/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py b/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py
index 447d8c08..793e07c9 100644
--- a/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py
+++ b/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py
@@ -89,17 +89,15 @@ async def downloadFileByPath(self, parameters: Dict[str, Any]) -> ActionResult:
"downloadFileByPath"
)
- # Save to user's Files (FileItem + FileData) via interfaceDbComponent – appears in Files UI
+ # Save to user's Files (FileItem + FileData) via chat service – appears in Files UI
fileItem = None
- db = getattr(self.services, "interfaceDbComponent", None)
- if db:
- try:
- mimeType = db.getMimeType(filename) if hasattr(db, "getMimeType") else "application/octet-stream"
- fileItem = db.createFile(name=filename, mimeType=mimeType, content=fileContent)
- db.createFileData(fileItem.id, fileContent)
- logger.info(f"Saved SharePoint file to user Files: {filename} (id={fileItem.id})")
- except Exception as e:
- logger.warning(f"Could not save to user Files: {e}")
+ try:
+ mimeType = self.services.chat.getMimeType(filename)
+ fileItem = self.services.chat.createFile(name=filename, mimeType=mimeType, content=fileContent)
+ self.services.chat.createFileData(fileItem.id, fileContent)
+ logger.info(f"Saved SharePoint file to user Files: {filename} (id={fileItem.id})")
+ except Exception as e:
+ logger.warning(f"Could not save to user Files: {e}")
# Encode as base64 for workflow context (AI, data nodes)
fileBase64 = base64.b64encode(fileContent).decode('utf-8')
diff --git a/modules/workflows/processing/modes/modeAutomation.py b/modules/workflows/processing/modes/modeAutomation.py
index 229bed5b..b6beabd3 100644
--- a/modules/workflows/processing/modes/modeAutomation.py
+++ b/modules/workflows/processing/modes/modeAutomation.py
@@ -349,7 +349,7 @@ class AutomationMode(BaseMode):
workflow = self.services.workflow
updateData = {"totalActions": totalActions}
workflow.totalActions = totalActions
- self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
+ self.services.chat.updateWorkflow(workflow.id, updateData)
logger.info(f"Updated workflow {workflow.id} after action planning: {updateData}")
except Exception as e:
logger.error(f"Error updating workflow after action planning: {str(e)}")
@@ -369,7 +369,7 @@ class AutomationMode(BaseMode):
updateData["totalActions"] = totalActions
if updateData:
- self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
+ self.services.chat.updateWorkflow(workflow.id, updateData)
logger.info(f"Updated workflow {workflow.id} totals: {updateData}")
except Exception as e:
logger.error(f"Error setting workflow totals: {str(e)}")
diff --git a/modules/workflows/processing/modes/modeBase.py b/modules/workflows/processing/modes/modeBase.py
index a8a3e048..684f5d52 100644
--- a/modules/workflows/processing/modes/modeBase.py
+++ b/modules/workflows/processing/modes/modeBase.py
@@ -67,8 +67,7 @@ class BaseMode(ABC):
if "execParameters" not in actionData:
actionData["execParameters"] = {}
- simpleFields, objectFields = self.services.interfaceDbChat._separateObjectFields(ActionItem, actionData)
- createdAction = self.services.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
+ createdAction = self.services.chat.createActionItem(actionData)
return ActionItem(
id=createdAction["id"],
@@ -103,7 +102,7 @@ class BaseMode(ABC):
workflow.currentTask = taskNumber
workflow.currentAction = 0
workflow.totalActions = 0
- self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
+ self.services.chat.updateWorkflow(workflow.id, updateData)
logger.info(f"Updated workflow {workflow.id} before executing task {taskNumber}")
except Exception as e:
logger.error(f"Error updating workflow before executing task: {str(e)}")
@@ -114,7 +113,7 @@ class BaseMode(ABC):
workflow = self.services.workflow
updateData = {"currentAction": actionNumber}
workflow.currentAction = actionNumber
- self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
+ self.services.chat.updateWorkflow(workflow.id, updateData)
logger.info(f"Updated workflow {workflow.id} before executing action {actionNumber}")
except Exception as e:
logger.error(f"Error updating workflow before executing action: {str(e)}")
diff --git a/modules/workflows/processing/workflowProcessor.py b/modules/workflows/processing/workflowProcessor.py
index d6fa00f0..5123f934 100644
--- a/modules/workflows/processing/workflowProcessor.py
+++ b/modules/workflows/processing/workflowProcessor.py
@@ -190,7 +190,7 @@ class WorkflowProcessor:
self.workflow.totalActions = 0
# Update in database
- self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData)
+ self.services.chat.updateWorkflow(self.workflow.id, updateData)
logger.info(f"Updated workflow {self.workflow.id} after task plan creation: {updateData}")
except Exception as e:
@@ -211,7 +211,7 @@ class WorkflowProcessor:
self.workflow.totalActions = 0
# Update in database
- self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData)
+ self.services.chat.updateWorkflow(self.workflow.id, updateData)
logger.info(f"Updated workflow {self.workflow.id} before executing task {taskNumber}: {updateData}")
except Exception as e:
@@ -228,7 +228,7 @@ class WorkflowProcessor:
self.workflow.totalActions = totalActions
# Update in database
- self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData)
+ self.services.chat.updateWorkflow(self.workflow.id, updateData)
logger.info(f"Updated workflow {self.workflow.id} after action planning: {updateData}")
except Exception as e:
@@ -245,7 +245,7 @@ class WorkflowProcessor:
self.workflow.currentAction = actionNumber
# Update in database
- self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData)
+ self.services.chat.updateWorkflow(self.workflow.id, updateData)
logger.info(f"Updated workflow {self.workflow.id} before executing action {actionNumber}: {updateData}")
except Exception as e:
@@ -266,7 +266,7 @@ class WorkflowProcessor:
# Update workflow object in database if we have changes
if updateData:
- self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData)
+ self.services.chat.updateWorkflow(self.workflow.id, updateData)
logger.info(f"Updated workflow {self.workflow.id} totals in database: {updateData}")
logger.debug(f"Updated workflow totals: Tasks {self.workflow.totalTasks if hasattr(self.workflow, 'totalTasks') else 'N/A'}, Actions {self.workflow.totalActions if hasattr(self.workflow, 'totalActions') else 'N/A'}")
@@ -290,7 +290,7 @@ class WorkflowProcessor:
self.workflow.totalActions = 0
# Update in database
- self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData)
+ self.services.chat.updateWorkflow(self.workflow.id, updateData)
logger.info(f"Reset workflow {self.workflow.id} for new session: {updateData}")
except Exception as e:
@@ -636,12 +636,12 @@ class WorkflowProcessor:
else:
contentBytes = json.dumps(rawData, ensure_ascii=False).encode('utf-8')
- fileItem = self.services.interfaceDbComponent.createFile(
+ fileItem = self.services.chat.createFile(
name=actionDoc.documentName if hasattr(actionDoc, 'documentName') else f"task_{taskResult.taskId}_result.txt",
mimeType=actionDoc.mimeType if hasattr(actionDoc, 'mimeType') else "text/plain",
content=contentBytes
)
- self.services.interfaceDbComponent.createFileData(
+ self.services.chat.createFileData(
fileItem.id,
contentBytes
)
diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py
index e983a139..7f06b325 100644
--- a/modules/workflows/workflowManager.py
+++ b/modules/workflows/workflowManager.py
@@ -118,7 +118,7 @@ class WorkflowManager:
"totalTasks": 0,
"totalActions": 0,
"mandateId": self.services.mandateId,
- "featureInstanceId": getattr(self.services, 'featureInstanceId', None), # Feature instance ID for isolation
+ "featureInstanceId": self.services.featureInstanceId,
"messageIds": [],
"workflowMode": workflowMode,
"maxSteps": 10 , # Set maxSteps
@@ -478,12 +478,12 @@ The following is the user's original input message. Analyze intent, normalize th
if userInput.prompt:
try:
originalPromptBytes = userInput.prompt.encode('utf-8')
- fileItem = self.services.interfaceDbComponent.createFile(
+ fileItem = self.services.chat.createFile(
name="user_prompt_original.md",
mimeType="text/markdown",
content=originalPromptBytes
)
- self.services.interfaceDbComponent.createFileData(fileItem.id, originalPromptBytes)
+ self.services.chat.createFileData(fileItem.id, originalPromptBytes)
fileInfo = self.services.chat.getFileInfo(fileItem.id)
doc = {
"fileId": fileItem.id,
@@ -544,13 +544,13 @@ The following is the user's original input message. Analyze intent, normalize th
for actionDoc in result.documents:
if hasattr(actionDoc, 'documentData') and actionDoc.documentData:
# Create file in component storage
- fileItem = self.services.interfaceDbComponent.createFile(
+ fileItem = self.services.chat.createFile(
name=actionDoc.documentName if hasattr(actionDoc, 'documentName') else "fast_path_response.txt",
mimeType=actionDoc.mimeType if hasattr(actionDoc, 'mimeType') else "text/plain",
content=actionDoc.documentData if isinstance(actionDoc.documentData, bytes) else actionDoc.documentData.encode('utf-8')
)
# Persist file data
- self.services.interfaceDbComponent.createFileData(fileItem.id, actionDoc.documentData if isinstance(actionDoc.documentData, bytes) else actionDoc.documentData.encode('utf-8'))
+ self.services.chat.createFileData(fileItem.id, actionDoc.documentData if isinstance(actionDoc.documentData, bytes) else actionDoc.documentData.encode('utf-8'))
# Get file info
fileInfo = self.services.chat.getFileInfo(fileItem.id)
@@ -667,12 +667,12 @@ The following is the user's original input message. Analyze intent, normalize th
if userInput.prompt:
try:
originalPromptBytes = userInput.prompt.encode('utf-8')
- fileItem = self.services.interfaceDbComponent.createFile(
+ fileItem = self.services.chat.createFile(
name="user_prompt_original.md",
mimeType="text/markdown",
content=originalPromptBytes
)
- self.services.interfaceDbComponent.createFileData(fileItem.id, originalPromptBytes)
+ self.services.chat.createFileData(fileItem.id, originalPromptBytes)
fileInfo = self.services.chat.getFileInfo(fileItem.id)
doc = {
"fileId": fileItem.id,
@@ -807,12 +807,12 @@ The following is the user's original input message. Analyze intent, normalize th
if userInput.prompt:
try:
originalPromptBytes = userInput.prompt.encode('utf-8')
- fileItem = self.services.interfaceDbComponent.createFile(
+ fileItem = self.services.chat.createFile(
name="user_prompt_original.md",
mimeType="text/markdown",
content=originalPromptBytes
)
- self.services.interfaceDbComponent.createFileData(fileItem.id, originalPromptBytes)
+ self.services.chat.createFileData(fileItem.id, originalPromptBytes)
fileInfo = self.services.chat.getFileInfo(fileItem.id)
doc = {
"fileId": fileItem.id,
diff --git a/tests/eval/runTrusteeBenchmark.py b/tests/eval/runTrusteeBenchmark.py
index 749bf996..7622b3d0 100644
--- a/tests/eval/runTrusteeBenchmark.py
+++ b/tests/eval/runTrusteeBenchmark.py
@@ -409,20 +409,39 @@ def _extractNumbers(text: str) -> List[float]:
def _bootstrapServices() -> Tuple[Any, str, str]:
- """Spin up a minimal service hub bound to the root user + initial mandate.
+ """Spin up a minimal services bag bound to the root user + initial mandate.
- Returns the ServiceHub, the user id, and the mandate id used for billing.
+ Returns a services bag, the user id, and the mandate id used for billing.
"""
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import Mandate
- from modules.serviceCenter.serviceHub import getInterface as getServices
+ from modules.serviceCenter import getService
+ from modules.serviceCenter.context import ServiceCenterContext
rootInterface = getRootInterface()
user = rootInterface.currentUser
mandateId = rootInterface.getInitialId(Mandate)
if not mandateId:
raise RuntimeError("No initial mandate available -- run bootstrap loader first.")
- services = getServices(user, workflow=None, mandateId=mandateId, featureInstanceId=None)
+
+ ctx = ServiceCenterContext(user=user, mandate_id=mandateId)
+
+ class _BenchmarkServicesBag:
+ def __init__(self, ctx):
+ self._ctx = ctx
+ self.user = ctx.user
+ self.mandateId = ctx.mandate_id
+ self.featureInstanceId = ctx.feature_instance_id
+ self.workflow = ctx.workflow
+
+ def __getattr__(self, name):
+ if name.startswith("_"):
+ raise AttributeError(name)
+ svc = getService(name, self._ctx)
+ setattr(self, name, svc)
+ return svc
+
+ services = _BenchmarkServicesBag(ctx)
return services, user.id, mandateId
diff --git a/tests/functional/test01_ai_model_selection.py b/tests/functional/test01_ai_model_selection.py
index 7c69b927..4c299a26 100644
--- a/tests/functional/test01_ai_model_selection.py
+++ b/tests/functional/test01_ai_model_selection.py
@@ -19,7 +19,8 @@ _gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".
if _gateway_path not in sys.path:
sys.path.insert(0, _gateway_path)
-from modules.serviceCenter.serviceHub import getInterface as getServices
+from modules.serviceCenter import getService
+from modules.serviceCenter.context import ServiceCenterContext
from modules.datamodels.datamodelAi import (
AiCallOptions,
AiCallRequest,
@@ -33,6 +34,23 @@ from modules.aicore.aicoreModelRegistry import modelRegistry
from modules.aicore.aicoreModelSelector import modelSelector
+class _TestServicesBag:
+ """Mutable services bag for tests — lazy-resolves via getService, allows attribute overrides."""
+ def __init__(self, ctx):
+ self._ctx = ctx
+ self.user = ctx.user
+ self.mandateId = ctx.mandate_id
+ self.featureInstanceId = ctx.feature_instance_id
+ self.workflow = ctx.workflow
+
+ def __getattr__(self, name):
+ if name.startswith("_"):
+ raise AttributeError(name)
+ svc = getService(name, self._ctx)
+ setattr(self, name, svc)
+ return svc
+
+
class ModelSelectionTester:
def __init__(self) -> None:
testUser = User(
@@ -43,7 +61,8 @@ class ModelSelectionTester:
language="en",
mandateId="test_mandate",
)
- self.services = getServices(testUser, None)
+ ctx = ServiceCenterContext(user=testUser)
+ self.services = _TestServicesBag(ctx)
async def initialize(self) -> None:
from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService
diff --git a/tests/functional/test02_ai_models.py b/tests/functional/test02_ai_models.py
index 32aeed80..4569455e 100644
--- a/tests/functional/test02_ai_models.py
+++ b/tests/functional/test02_ai_models.py
@@ -31,14 +31,31 @@ _gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".
if _gateway_path not in sys.path:
sys.path.insert(0, _gateway_path)
-# Import the service initialization
-from modules.serviceCenter.serviceHub import getInterface as getServices
+from modules.serviceCenter import getService
+from modules.serviceCenter.context import ServiceCenterContext
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum
from modules.datamodels.datamodelUam import User
+
+class _TestServicesBag:
+ """Mutable services bag for tests — lazy-resolves via getService, allows attribute overrides."""
+ def __init__(self, ctx):
+ self._ctx = ctx
+ self.user = ctx.user
+ self.mandateId = ctx.mandate_id
+ self.featureInstanceId = ctx.feature_instance_id
+ self.workflow = ctx.workflow
+
+ def __getattr__(self, name):
+ if name.startswith("_"):
+ raise AttributeError(name)
+ svc = getService(name, self._ctx)
+ setattr(self, name, svc)
+ return svc
+
+
class AIModelsTester:
def __init__(self):
- # Create a minimal user context for testing
testUser = User(
id="test_user",
username="test_user",
@@ -48,8 +65,8 @@ class AIModelsTester:
mandateId="test_mandate"
)
- # Initialize services using the existing system
- self.services = getServices(testUser, None) # Test user, no workflow
+ ctx = ServiceCenterContext(user=testUser)
+ self.services = _TestServicesBag(ctx)
self.testResults = []
# Create logs directory if it doesn't exist (go up 2 levels from tests/unit/services/)
diff --git a/tests/functional/test03_ai_operations.py b/tests/functional/test03_ai_operations.py
index 835078f0..ee38af8b 100644
--- a/tests/functional/test03_ai_operations.py
+++ b/tests/functional/test03_ai_operations.py
@@ -20,6 +20,8 @@ if _gateway_path not in sys.path:
from modules.datamodels.datamodelAi import OperationTypeEnum
from modules.datamodels.datamodelChat import ChatWorkflow, ChatDocument, WorkflowModeEnum
from modules.datamodels.datamodelUam import User
+from modules.serviceCenter import getService
+from modules.serviceCenter.context import ServiceCenterContext
class MethodAiOperationsTester:
@@ -96,15 +98,27 @@ class MethodAiOperationsTester:
import logging
logging.getLogger().setLevel(logging.DEBUG)
- # Import and initialize services
import modules.interfaces.interfaceDbChat as interfaceFeatureAiChat
- interfaceDbChat = interfaceDbChat.getInterface(self.testUser)
+ interfaceDbChat = interfaceFeatureAiChat.getInterface(self.testUser)
- # Import and initialize services
- from modules.serviceCenter.serviceHub import getInterface as getServices
-
- # Get services first
- self.services = getServices(self.testUser, None)
+ ctx = ServiceCenterContext(user=self.testUser, mandate_id=self.testMandateId)
+
+ class _TestServicesBag:
+ def __init__(self, ctx):
+ self._ctx = ctx
+ self.user = ctx.user
+ self.mandateId = ctx.mandate_id
+ self.featureInstanceId = ctx.feature_instance_id
+ self.workflow = ctx.workflow
+
+ def __getattr__(self, name):
+ if name.startswith("_"):
+ raise AttributeError(name)
+ svc = getService(name, self._ctx)
+ setattr(self, name, svc)
+ return svc
+
+ self.services = _TestServicesBag(ctx)
# Now create AND SAVE workflow in database using the interface
import uuid
diff --git a/tests/functional/test04_ai_behavior.py b/tests/functional/test04_ai_behavior.py
index 7845733a..276b9283 100644
--- a/tests/functional/test04_ai_behavior.py
+++ b/tests/functional/test04_ai_behavior.py
@@ -16,26 +16,40 @@ _gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".
if _gateway_path not in sys.path:
sys.path.insert(0, _gateway_path)
-# Import the service initialization
-from modules.serviceCenter.serviceHub import getInterface as getServices
+from modules.serviceCenter import getService
+from modules.serviceCenter.context import ServiceCenterContext
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelWorkflow import AiResponse
-# The test uses the AI service which handles JSON template internally
+
+class _TestServicesBag:
+ """Mutable services bag for tests — lazy-resolves via getService, allows attribute overrides."""
+ def __init__(self, ctx):
+ self._ctx = ctx
+ self.user = ctx.user
+ self.mandateId = ctx.mandate_id
+ self.featureInstanceId = ctx.feature_instance_id
+ self.workflow = ctx.workflow
+
+ def __getattr__(self, name):
+ if name.startswith("_"):
+ raise AttributeError(name)
+ svc = getService(name, self._ctx)
+ setattr(self, name, svc)
+ return svc
+
class AIBehaviorTester:
def __init__(self):
- # Use root user for testing (has full access to everything)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import Mandate
rootInterface = getRootInterface()
self.testUser = rootInterface.currentUser
- # Get initial mandate ID for testing (User has no mandateId - use initial mandate)
self.testMandateId = rootInterface.getInitialId(Mandate)
- # Initialize services using the existing system
- self.services = getServices(self.testUser, None) # Test user, no workflow
+ ctx = ServiceCenterContext(user=self.testUser)
+ self.services = _TestServicesBag(ctx)
self.testResults = []
async def initialize(self):
diff --git a/tests/unit/workflow/test_extract_content_handover.py b/tests/unit/workflow/test_extract_content_handover.py
index 9153f350..8e8f409c 100644
--- a/tests/unit/workflow/test_extract_content_handover.py
+++ b/tests/unit/workflow/test_extract_content_handover.py
@@ -395,14 +395,14 @@ def test_action_result_contract_new_extract_payload_keys():
def test_automation_workspace_suppresses_extract_artifacts():
- from modules.workflowAutomation.engine.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui
+ from modules.shared.workflowArtifactVisibility import suppressWorkflowFileInWorkspaceUi
- 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"})
- assert not suppress_workflow_file_in_workspace_ui({"fileName": "export_2026.csv"})
- assert suppress_workflow_file_in_workspace_ui({"fileName": "", "suppressInWorkflowFileLists": True})
- assert suppress_workflow_file_in_workspace_ui({"fileName": "report.pdf", "tags": ["_workflowInternal"]})
- assert not suppress_workflow_file_in_workspace_ui({"fileName": "report.pdf", "tags": ["invoice"]})
+ assert suppressWorkflowFileInWorkspaceUi({"fileName": "extracted_content_transient-abc_99.json"})
+ assert suppressWorkflowFileInWorkspaceUi({"fileName": "extract_media_stem_uuid.png"})
+ assert not suppressWorkflowFileInWorkspaceUi({"fileName": "export_2026.csv"})
+ assert suppressWorkflowFileInWorkspaceUi({"fileName": "", "suppressInWorkflowFileLists": True})
+ assert suppressWorkflowFileInWorkspaceUi({"fileName": "report.pdf", "tags": ["_workflowInternal"]})
+ assert not suppressWorkflowFileInWorkspaceUi({"fileName": "report.pdf", "tags": ["invoice"]})
def test_normalize_presentation_envelopes_action_result_and_list():
From 26dd8f6f3f1fa097c64881d8ee5e77a5b43658ca Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 9 Jun 2026 07:05:06 +0200
Subject: [PATCH 11/16] cleanup intra referencings in codebase
---
app.py | 14 ++
modules/aicore/aicoreModelRegistry.py | 13 +-
modules/datamodels/datamodelRbac.py | 16 +-
modules/demoConfigs/investorDemo2026.py | 2 +-
modules/demoConfigs/pwgDemo2026.py | 2 +-
.../features/commcoach/serviceCommcoach.py | 20 +--
.../interfaceFeatureNeutralizer.py | 4 +-
.../neutralization/neutralizePlayground.py | 4 +-
.../neutralization/routeFeatureNeutralizer.py | 11 +-
.../mainServiceNeutralization.py | 4 +-
.../features/realEstate/serviceAiIntent.py | 2 +-
modules/features/realEstate/serviceBzo.py | 4 +-
.../redmine/interfaceFeatureRedmine.py | 137 +++++++++++++++++-
.../features/redmine/routeFeatureRedmine.py | 2 +-
modules/features/redmine/serviceRedmine.py | 129 +----------------
.../features/redmine/serviceRedmineStats.py | 6 +-
.../features/redmine/serviceRedmineSync.py | 4 +-
modules/features/teamsbot/service.py | 12 +-
modules/features/teamsbot/serviceCommands.py | 8 +-
.../workspace/routeFeatureWorkspace.py | 28 ++--
modules/interfaces/interfaceBootstrap.py | 91 +-----------
modules/interfaces/interfaceDbApp.py | 98 ++++---------
modules/interfaces/interfaceDbBilling.py | 80 ++++++++++
modules/interfaces/interfaceDbKnowledge.py | 35 ++---
modules/interfaces/interfaceRbac.py | 98 ++++++++++++-
modules/routes/routeDataConnections.py | 2 +-
modules/routes/routeDataFiles.py | 24 +--
modules/routes/routeRagInventory.py | 6 +-
modules/routes/routeVoiceUser.py | 2 +-
modules/routes/routeWorkflowAutomation.py | 24 ++-
modules/serviceCenter/__init__.py | 2 +-
modules/serviceCenter/context.py | 18 +--
.../flagResolution.py} | 0
.../serviceSecurity/mainServiceSecurity.py | 4 +-
.../serviceStreaming/mainServiceStreaming.py | 2 +-
.../core/serviceUtils/mainServiceUtils.py | 2 +-
modules/serviceCenter/core/types.py | 90 ++++++++++++
modules/serviceCenter/resolver.py | 2 +-
.../services/serviceAgent/__init__.py | 5 +
.../coreTools/_dataSourceTools.py | 2 +-
.../coreTools/_featureSubAgentTools.py | 4 +-
.../services/serviceAgent/mainServiceAgent.py | 4 +-
.../services/serviceAi/mainServiceAi.py | 38 ++---
.../serviceBilling/mainServiceBilling.py | 16 +-
.../services/serviceChat/mainServiceChat.py | 20 +--
.../serviceClickup/mainServiceClickup.py | 4 +-
.../mainServiceExtraction.py | 58 ++++----
.../subPromptBuilderExtraction.py | 10 +-
.../mainServiceGeneration.py | 14 +-
.../services/serviceKnowledge/_buildTree.py | 18 +--
.../serviceKnowledge/mainServiceKnowledge.py | 2 +-
.../subConnectorIngestConsumer.py | 2 +-
.../subConnectorSyncClickup.py | 2 +-
.../subConnectorSyncGdrive.py | 2 +-
.../serviceKnowledge/subConnectorSyncGmail.py | 2 +-
.../subConnectorSyncKdrive.py | 2 +-
.../subConnectorSyncOutlook.py | 2 +-
.../subConnectorSyncSharepoint.py | 2 +-
.../serviceKnowledge/subFeatureBootstrap.py | 10 +-
.../services/serviceKnowledge/udbNodes.py | 14 +-
.../serviceMessaging/mainServiceMessaging.py | 2 +-
.../mainServiceSharepoint.py | 6 +-
.../mainServiceSubscription.py | 4 +-
.../serviceTicket/mainServiceTicket.py | 2 +-
.../services/serviceWeb/mainServiceWeb.py | 86 +++++------
modules/shared/systemComponentRegistry.py | 4 +-
.../editor/_valueKindResolver.py | 102 +++++++++++++
.../editor/conditionOperators.py | 102 ++++---------
.../editor/upstreamPathsService.py | 9 +-
.../engine/_runNotifications.py | 118 +++++++++++++++
.../engine/executionEngine.py | 11 +-
.../workflowAutomation/engine/graphUtils.py | 18 ++-
.../engine/pickNotPushMigration.py | 20 +--
.../mainWorkflowAutomation.py | 4 +-
.../workflowAutomation/scheduler/__init__.py | 2 +
.../scheduler/mainScheduler.py | 98 ++-----------
.../shared/promptGenerationActionsDynamic.py | 4 +-
.../mandates/test_createMandate.py | 2 +-
78 files changed, 1048 insertions(+), 781 deletions(-)
rename modules/serviceCenter/{services/serviceKnowledge/_inheritFlags.py => core/flagResolution.py} (100%)
create mode 100644 modules/serviceCenter/core/types.py
create mode 100644 modules/workflowAutomation/editor/_valueKindResolver.py
create mode 100644 modules/workflowAutomation/engine/_runNotifications.py
diff --git a/app.py b/app.py
index f76f7083..31085e6b 100644
--- a/app.py
+++ b/app.py
@@ -318,9 +318,23 @@ async def lifespan(app: FastAPI):
onMandateDelete as _waOnMandateDelete,
onInstanceCreate as _waOnInstanceCreate,
)
+ from modules.interfaces.interfaceDbBilling import (
+ onMandateDelete as _billingOnMandateDelete,
+ onMandateProvision as _billingOnMandateProvision,
+ onStorageChanged as _billingOnStorageChanged,
+ onUserMandateCreate as _billingOnUserMandateCreate,
+ onUserMandateDelete as _billingOnUserMandateDelete,
+ onUserBudgetAdjust as _billingOnUserBudgetAdjust,
+ )
registerLifecycleHook("onBootstrap", _waOnBootstrap)
registerLifecycleHook("onMandateDelete", _waOnMandateDelete)
+ registerLifecycleHook("onMandateDelete", _billingOnMandateDelete)
+ registerLifecycleHook("onMandateProvision", _billingOnMandateProvision)
+ registerLifecycleHook("onStorageChanged", _billingOnStorageChanged)
registerLifecycleHook("onInstanceCreate", _waOnInstanceCreate)
+ registerLifecycleHook("onUserMandateCreate", _billingOnUserMandateCreate)
+ registerLifecycleHook("onUserMandateDelete", _billingOnUserMandateDelete)
+ registerLifecycleHook("onUserBudgetAdjust", _billingOnUserBudgetAdjust)
# Bootstrap database if needed (creates initial users, mandates, roles, etc.)
# This must happen before getting root interface
diff --git a/modules/aicore/aicoreModelRegistry.py b/modules/aicore/aicoreModelRegistry.py
index 164f71f9..8c57e0c4 100644
--- a/modules/aicore/aicoreModelRegistry.py
+++ b/modules/aicore/aicoreModelRegistry.py
@@ -10,16 +10,13 @@ import importlib
import os
import time
import threading
-from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING
+from typing import Dict, List, Optional, Any, Tuple
from modules.datamodels.datamodelAi import AiModel
-from modules.datamodels.datamodelRbac import AccessRuleContext
+from modules.datamodels.datamodelRbac import AccessRuleContext, RbacProtocol
from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector
-if TYPE_CHECKING:
- from modules.security.rbac import RbacClass
-
logger = logging.getLogger(__name__)
# TODO TESTING: Override maxTokens for all models during testing
@@ -188,7 +185,7 @@ class ModelRegistry:
def getAvailableModels(
self,
currentUser: Optional[User] = None,
- rbacInstance: Optional["RbacClass"] = None,
+ rbacInstance: Optional[RbacProtocol] = None,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> List[AiModel]:
@@ -239,7 +236,7 @@ class ModelRegistry:
self,
models: List[AiModel],
currentUser: User,
- rbacInstance: "RbacClass",
+ rbacInstance: RbacProtocol,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> List[AiModel]:
@@ -264,7 +261,7 @@ class ModelRegistry:
logger.debug(f"User {currentUser.username} does not have access to model {model.displayName} (connector: {model.connectorType})")
return filteredModels
- def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional["RbacClass"] = None) -> Optional[AiModel]:
+ def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacProtocol] = None) -> Optional[AiModel]:
"""Get a specific model by displayName, optionally checking RBAC permissions.
Args:
diff --git a/modules/datamodels/datamodelRbac.py b/modules/datamodels/datamodelRbac.py
index 83a4525d..7ea9d710 100644
--- a/modules/datamodels/datamodelRbac.py
+++ b/modules/datamodels/datamodelRbac.py
@@ -10,7 +10,7 @@ Multi-Tenant Design:
"""
import uuid
-from typing import Optional
+from typing import Optional, Dict, List, Protocol, runtime_checkable
from enum import Enum
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
@@ -174,6 +174,20 @@ class AccessRule(PowerOnModel):
)
+@runtime_checkable
+class RbacProtocol(Protocol):
+ """Structural type for RBAC checkers — allows aicore (L3) to reference
+ the RBAC contract without importing from security (L4)."""
+
+ def checkResourceAccessBulk(
+ self,
+ user: "User",
+ resourcePaths: List[str],
+ mandateId: Optional[str] = None,
+ featureInstanceId: Optional[str] = None,
+ ) -> Dict[str, bool]: ...
+
+
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level
IMMUTABLE_FIELDS = {
"Role": ["mandateId", "featureInstanceId", "featureCode"],
diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py
index 84fc5e01..e88ce6c7 100644
--- a/modules/demoConfigs/investorDemo2026.py
+++ b/modules/demoConfigs/investorDemo2026.py
@@ -169,7 +169,7 @@ class InvestorDemo2026(BaseDemoConfig):
def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]:
from modules.datamodels.datamodelUam import Mandate
- from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
+ from modules.interfaces.interfaceRbac import copySystemRolesToMandate
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
if existing:
diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py
index 2d301f7a..90e3c3e4 100644
--- a/modules/demoConfigs/pwgDemo2026.py
+++ b/modules/demoConfigs/pwgDemo2026.py
@@ -154,7 +154,7 @@ class PwgDemo2026(BaseDemoConfig):
def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]:
from modules.datamodels.datamodelUam import Mandate
- from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
+ from modules.interfaces.interfaceRbac import copySystemRolesToMandate
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
if existing:
diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py
index d7a79d1f..3aa0c1a0 100644
--- a/modules/features/commcoach/serviceCommcoach.py
+++ b/modules/features/commcoach/serviceCommcoach.py
@@ -597,8 +597,8 @@ def _createCommcoachRagFn(
from modules.serviceCenter.context import ServiceCenterContext
serviceContext = ServiceCenterContext(
user=currentUser,
- mandate_id=mandateId,
- feature_instance_id=featureInstanceId,
+ mandateId=mandateId,
+ featureInstanceId=featureInstanceId,
)
knowledgeService = getService("knowledge", serviceContext)
ragContext = await knowledgeService.buildAgentContext(
@@ -902,8 +902,8 @@ class CommcoachService:
serviceContext = ServiceCenterContext(
user=self.currentUser,
- mandate_id=self.mandateId,
- feature_instance_id=self.instanceId,
+ mandateId=self.mandateId,
+ featureInstanceId=self.instanceId,
)
agentService = getService("agent", serviceContext)
@@ -1240,8 +1240,8 @@ class CommcoachService:
serviceContext = ServiceCenterContext(
user=self.currentUser,
- mandate_id=self.mandateId,
- feature_instance_id=self.instanceId,
+ mandateId=self.mandateId,
+ featureInstanceId=self.instanceId,
)
knowledgeService = getService("knowledge", serviceContext)
parsedGoals = aiPrompts._parseJsonField(context.get("goals") if context else None, [])
@@ -1535,8 +1535,8 @@ class CommcoachService:
serviceContext = ServiceCenterContext(
user=self.currentUser,
- mandate_id=self.mandateId,
- feature_instance_id=self.instanceId,
+ mandateId=self.mandateId,
+ featureInstanceId=self.instanceId,
)
aiService = getService("ai", serviceContext)
await aiService.ensureAiObjectsInitialized()
@@ -1561,8 +1561,8 @@ class CommcoachService:
serviceContext = ServiceCenterContext(
user=self.currentUser,
- mandate_id=self.mandateId,
- feature_instance_id=self.instanceId,
+ mandateId=self.mandateId,
+ featureInstanceId=self.instanceId,
)
aiService = getService("ai", serviceContext)
await aiService.ensureAiObjectsInitialized()
diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py
index 3d5c9129..97a466ff 100644
--- a/modules/features/neutralization/interfaceFeatureNeutralizer.py
+++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py
@@ -309,8 +309,8 @@ class InterfaceFeatureNeutralizer:
) -> Optional[DataNeutralizerAttributes]:
"""Create a neutralization attribute for placeholder resolution."""
try:
- mandate_id = self.mandateId or ""
- feature_instance_id = self.featureInstanceId or ""
+ mandateId = self.mandateId or ""
+ featureInstanceId = self.featureInstanceId or ""
if not self.userId:
logger.warning("Cannot create attribute: missing userId")
return None
diff --git a/modules/features/neutralization/neutralizePlayground.py b/modules/features/neutralization/neutralizePlayground.py
index e855ad22..1a46cd25 100644
--- a/modules/features/neutralization/neutralizePlayground.py
+++ b/modules/features/neutralization/neutralizePlayground.py
@@ -22,7 +22,7 @@ class NeutralizationPlayground:
self.currentUser = currentUser
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
- self._ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=featureInstanceId)
+ self._ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
def _getService(self, name: str):
return getService(name, self._ctx)
@@ -258,7 +258,7 @@ class SharepointProcessor:
self._sharepoint = getService("sharepoint", ctx)
self._neutralization = getService("neutralization", ctx)
from modules.interfaces.interfaceDbApp import getInterface as _getAppInterface
- self._interfaceDbApp = _getAppInterface(currentUser, mandateId=ctx.mandate_id)
+ self._interfaceDbApp = _getAppInterface(currentUser, mandateId=ctx.mandateId)
async def processSharepointFiles(self, sourcePath: str, targetPath: str) -> Dict[str, Any]:
try:
diff --git a/modules/features/neutralization/routeFeatureNeutralizer.py b/modules/features/neutralization/routeFeatureNeutralizer.py
index bf396e3b..488ef352 100644
--- a/modules/features/neutralization/routeFeatureNeutralizer.py
+++ b/modules/features/neutralization/routeFeatureNeutralizer.py
@@ -58,18 +58,17 @@ def get_neutralization_config(
) -> DataNeutraliserConfig:
"""Get data neutralization configuration"""
try:
- mandate_id = str(context.mandateId) if context.mandateId else ""
- feature_instance_id = str(context.featureInstanceId) if context.featureInstanceId else ""
+ mandateId = str(context.mandateId) if context.mandateId else ""
+ featureInstanceId = str(context.featureInstanceId) if context.featureInstanceId else ""
service = NeutralizationPlayground(
- context.user, mandate_id, featureInstanceId=feature_instance_id or None
+ context.user, mandateId, featureInstanceId=featureInstanceId or None
)
config = service.getConfig()
if not config:
- # Return default config instead of 404 (requires mandateId and featureInstanceId for instance-scoped config)
return DataNeutraliserConfig(
- mandateId=mandate_id,
- featureInstanceId=feature_instance_id,
+ mandateId=mandateId,
+ featureInstanceId=featureInstanceId,
userId=context.user.id,
enabled=True,
namesToParse="",
diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
index 4cfec864..0388dbba 100644
--- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
+++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
@@ -64,8 +64,8 @@ class NeutralizationService:
elif serviceCenter and getattr(serviceCenter, "user", None):
self.interfaceNeutralizer = getNeutralizerInterface(
currentUser=serviceCenter.user,
- mandateId=getattr(serviceCenter, 'mandateId', None) or getattr(serviceCenter, 'mandate_id', None),
- featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(serviceCenter, 'feature_instance_id', None),
+ mandateId=getattr(serviceCenter, 'mandateId', None),
+ featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None),
)
namesList = NamesToParse if isinstance(NamesToParse, list) else []
diff --git a/modules/features/realEstate/serviceAiIntent.py b/modules/features/realEstate/serviceAiIntent.py
index ca53c98e..d790d7c8 100644
--- a/modules/features/realEstate/serviceAiIntent.py
+++ b/modules/features/realEstate/serviceAiIntent.py
@@ -232,7 +232,7 @@ async def processNaturalLanguageCommand(
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})")
logger.debug(f"User input: {userInput}")
- ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId)
+ ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId)
aiService = getService("ai", ctx)
intentAnalysis = await analyzeUserIntent(aiService, userInput)
diff --git a/modules/features/realEstate/serviceBzo.py b/modules/features/realEstate/serviceBzo.py
index 178c8021..f4ec90bd 100644
--- a/modules/features/realEstate/serviceBzo.py
+++ b/modules/features/realEstate/serviceBzo.py
@@ -234,7 +234,7 @@ async def extract_bzo_information(
bzo_params_result = None
try:
- ctx = ServiceCenterContext(user=currentUser, mandate_id=_mandateId, feature_instance_id=featureInstanceId)
+ ctx = ServiceCenterContext(user=currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId)
ai_service = getService("ai", ctx)
bzo_params_result = await run_bzo_params_extraction(
extracted_content=all_extracted_content,
@@ -520,7 +520,7 @@ async def generate_bauzone_ai_summary(
AI-generated summary string
"""
try:
- ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=featureInstanceId)
+ ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
aiService = getService("ai", ctx)
context_parts = []
diff --git a/modules/features/redmine/interfaceFeatureRedmine.py b/modules/features/redmine/interfaceFeatureRedmine.py
index 88855501..225b5a31 100644
--- a/modules/features/redmine/interfaceFeatureRedmine.py
+++ b/modules/features/redmine/interfaceFeatureRedmine.py
@@ -13,7 +13,7 @@ from __future__ import annotations
import logging
import time
-from typing import Any, Dict, Optional
+from typing import Any, Dict, List, Optional, Tuple
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.connectors.connectorTicketsRedmine import ConnectorTicketsRedmine
@@ -21,6 +21,9 @@ from modules.datamodels.datamodelUam import User
from modules.features.redmine.datamodelRedmine import (
RedmineConfigDto,
RedmineConfigUpdateRequest,
+ RedmineCustomFieldSchemaDto,
+ RedmineFieldChoiceDto,
+ RedmineFieldSchemaDto,
RedmineInstanceConfig,
RedmineRelationMirror,
RedmineTicketMirror,
@@ -447,3 +450,135 @@ def getInterface(
featureInstanceId=effectiveFeatureInstanceId,
)
return _redmineInterfaces[contextKey]
+
+
+# ---------------------------------------------------------------------------
+# Project meta -- with TTL cache stored on the config record
+# ---------------------------------------------------------------------------
+
+class RedmineNotConfiguredError(RuntimeError):
+ """The given feature instance has no usable Redmine config."""
+
+
+def _resolveRootTrackerId(
+ rootTrackerName: str, trackers: List[Dict[str, Any]]
+) -> Optional[int]:
+ """Resolve the configured root tracker name to a tracker id.
+
+ Strict: case-insensitive exact match. Returns ``None`` if not found
+ (the UI must surface this as a config error).
+ """
+ target = (rootTrackerName or "").strip().lower()
+ if not target:
+ return None
+ for t in trackers:
+ if str(t.get("name") or "").strip().lower() == target:
+ tid = t.get("id")
+ return int(tid) if tid is not None else None
+ return None
+
+
+def _schemaFromCache(
+ projectId: str, cache: Optional[Dict[str, Any]], rootTrackerName: str
+) -> Optional[RedmineFieldSchemaDto]:
+ if not cache:
+ return None
+ trackers = cache.get("trackers") or []
+ return RedmineFieldSchemaDto(
+ projectId=projectId,
+ projectName=str(cache.get("projectName") or ""),
+ trackers=[RedmineFieldChoiceDto(**t) for t in trackers],
+ statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []],
+ priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []],
+ users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []],
+ categories=[RedmineFieldChoiceDto(**c) for c in cache.get("categories") or []],
+ customFields=[
+ RedmineCustomFieldSchemaDto(
+ id=cf.get("id"),
+ name=cf.get("name", ""),
+ fieldFormat=cf.get("fieldFormat", "string"),
+ isRequired=bool(cf.get("isRequired")),
+ possibleValues=list(cf.get("possibleValues") or []),
+ multiple=bool(cf.get("multiple")),
+ defaultValue=cf.get("defaultValue"),
+ )
+ for cf in cache.get("customFields") or []
+ if cf.get("id") is not None
+ ],
+ rootTrackerName=rootTrackerName,
+ rootTrackerId=_resolveRootTrackerId(rootTrackerName, trackers),
+ )
+
+
+async def getProjectMeta(
+ currentUser: User,
+ mandateId: Optional[str],
+ featureInstanceId: str,
+ *,
+ forceRefresh: bool = False,
+) -> RedmineFieldSchemaDto:
+ """Fetch (or return cached) project metadata: trackers, statuses, priorities, etc."""
+ iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
+ connector = iface.resolveConnector(featureInstanceId)
+ if not connector:
+ raise RedmineNotConfiguredError(
+ f"Redmine instance {featureInstanceId} is not configured or inactive"
+ )
+ cfg = iface.getConfig(featureInstanceId)
+ if cfg is None:
+ raise RedmineNotConfiguredError("Config row vanished after connector resolve")
+
+ ttl = cfg.schemaCacheTtlSeconds if cfg.schemaCacheTtlSeconds is not None else 24 * 60 * 60
+ fresh_enough = (
+ cfg.schemaCache
+ and cfg.schemaCachedAt
+ and (time.time() - cfg.schemaCachedAt) < ttl
+ )
+ if fresh_enough and not forceRefresh:
+ schema = _schemaFromCache(cfg.projectId, cfg.schemaCache, cfg.rootTrackerName)
+ if schema is not None:
+ return schema
+
+ project_info = await connector.getProjectInfo()
+ trackers_raw = await connector.getTrackers()
+ statuses_raw = await connector.getStatuses()
+ priorities_raw = await connector.getPriorities()
+ custom_fields_raw = await connector.getCustomFields()
+ users_raw = await connector.getProjectUsers()
+ categories_raw = await connector.getIssueCategories()
+
+ schema_cache: Dict[str, Any] = {
+ "projectName": project_info.get("name", ""),
+ "trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers_raw],
+ "statuses": [
+ {
+ "id": s.get("id"),
+ "name": s.get("name"),
+ "isClosed": bool(s.get("is_closed")),
+ }
+ for s in statuses_raw
+ ],
+ "priorities": [{"id": p.get("id"), "name": p.get("name")} for p in priorities_raw],
+ "users": [{"id": u.get("id"), "name": u.get("name")} for u in users_raw],
+ "categories": [{"id": c.get("id"), "name": c.get("name")} for c in categories_raw if c.get("id") is not None],
+ "customFields": [
+ {
+ "id": cf.get("id"),
+ "name": cf.get("name"),
+ "fieldFormat": cf.get("field_format", "string"),
+ "isRequired": bool(cf.get("is_required")),
+ "possibleValues": [pv.get("value") for pv in (cf.get("possible_values") or []) if pv.get("value") is not None],
+ "multiple": bool(cf.get("multiple")),
+ "defaultValue": cf.get("default_value"),
+ }
+ for cf in custom_fields_raw
+ ],
+ }
+ iface.updateSchemaCache(featureInstanceId, schema_cache)
+ iface.markConfigConnected(featureInstanceId)
+
+ return _schemaFromCache(cfg.projectId, schema_cache, cfg.rootTrackerName) or RedmineFieldSchemaDto(
+ projectId=cfg.projectId,
+ projectName=schema_cache["projectName"],
+ rootTrackerName=cfg.rootTrackerName,
+ )
diff --git a/modules/features/redmine/routeFeatureRedmine.py b/modules/features/redmine/routeFeatureRedmine.py
index d973e690..bdc8797b 100644
--- a/modules/features/redmine/routeFeatureRedmine.py
+++ b/modules/features/redmine/routeFeatureRedmine.py
@@ -32,7 +32,7 @@ from modules.features.redmine.datamodelRedmine import (
RedmineTicketDto,
RedmineTicketUpdateRequest,
)
-from modules.features.redmine.serviceRedmine import RedmineNotConfiguredError
+from modules.features.redmine.interfaceFeatureRedmine import RedmineNotConfiguredError
from modules.connectors.connectorTicketsRedmine import RedmineApiError
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
diff --git a/modules/features/redmine/serviceRedmine.py b/modules/features/redmine/serviceRedmine.py
index b4a3d137..d772478b 100644
--- a/modules/features/redmine/serviceRedmine.py
+++ b/modules/features/redmine/serviceRedmine.py
@@ -25,7 +25,6 @@ workflow engine without context-magic.
from __future__ import annotations
import logging
-import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
@@ -35,9 +34,7 @@ from modules.connectors.connectorTicketsRedmine import (
)
from modules.datamodels.datamodelUam import User
from modules.features.redmine.datamodelRedmine import (
- RedmineCustomFieldSchemaDto,
RedmineCustomFieldValueDto,
- RedmineFieldChoiceDto,
RedmineFieldSchemaDto,
RedmineRelationCreateRequest,
RedmineRelationDto,
@@ -46,8 +43,10 @@ from modules.features.redmine.datamodelRedmine import (
RedmineTicketUpdateRequest,
)
from modules.features.redmine.interfaceFeatureRedmine import (
+ RedmineNotConfiguredError,
RedmineObjects,
getInterface,
+ getProjectMeta,
)
from modules.features.redmine.serviceRedmineStatsCache import getStatsCache
@@ -58,9 +57,6 @@ logger = logging.getLogger(__name__)
# Resolution helpers
# ---------------------------------------------------------------------------
-class RedmineNotConfiguredError(RuntimeError):
- """The given feature instance has no usable Redmine config."""
-
def _resolveContext(
currentUser: User, mandateId: Optional[str], featureInstanceId: str
@@ -74,127 +70,6 @@ def _resolveContext(
return iface, connector
-# ---------------------------------------------------------------------------
-# Project meta -- with TTL cache stored on the config record
-# ---------------------------------------------------------------------------
-
-async def getProjectMeta(
- currentUser: User,
- mandateId: Optional[str],
- featureInstanceId: str,
- *,
- forceRefresh: bool = False,
-) -> RedmineFieldSchemaDto:
- iface, connector = _resolveContext(currentUser, mandateId, featureInstanceId)
- cfg = iface.getConfig(featureInstanceId)
- if cfg is None:
- raise RedmineNotConfiguredError("Config row vanished after connector resolve")
-
- ttl = cfg.schemaCacheTtlSeconds if cfg.schemaCacheTtlSeconds is not None else 24 * 60 * 60
- fresh_enough = (
- cfg.schemaCache
- and cfg.schemaCachedAt
- and (time.time() - cfg.schemaCachedAt) < ttl
- )
- if fresh_enough and not forceRefresh:
- schema = _schemaFromCache(cfg.projectId, cfg.schemaCache, cfg.rootTrackerName)
- if schema is not None:
- return schema
-
- project_info = await connector.getProjectInfo()
- trackers_raw = await connector.getTrackers()
- statuses_raw = await connector.getStatuses()
- priorities_raw = await connector.getPriorities()
- custom_fields_raw = await connector.getCustomFields()
- users_raw = await connector.getProjectUsers()
- categories_raw = await connector.getIssueCategories()
-
- schema_cache: Dict[str, Any] = {
- "projectName": project_info.get("name", ""),
- "trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers_raw],
- "statuses": [
- {
- "id": s.get("id"),
- "name": s.get("name"),
- "isClosed": bool(s.get("is_closed")),
- }
- for s in statuses_raw
- ],
- "priorities": [{"id": p.get("id"), "name": p.get("name")} for p in priorities_raw],
- "users": [{"id": u.get("id"), "name": u.get("name")} for u in users_raw],
- "categories": [{"id": c.get("id"), "name": c.get("name")} for c in categories_raw if c.get("id") is not None],
- "customFields": [
- {
- "id": cf.get("id"),
- "name": cf.get("name"),
- "fieldFormat": cf.get("field_format", "string"),
- "isRequired": bool(cf.get("is_required")),
- "possibleValues": [pv.get("value") for pv in (cf.get("possible_values") or []) if pv.get("value") is not None],
- "multiple": bool(cf.get("multiple")),
- "defaultValue": cf.get("default_value"),
- }
- for cf in custom_fields_raw
- ],
- }
- iface.updateSchemaCache(featureInstanceId, schema_cache)
- iface.markConfigConnected(featureInstanceId)
-
- return _schemaFromCache(cfg.projectId, schema_cache, cfg.rootTrackerName) or RedmineFieldSchemaDto(
- projectId=cfg.projectId,
- projectName=schema_cache["projectName"],
- rootTrackerName=cfg.rootTrackerName,
- )
-
-
-def _resolveRootTrackerId(
- rootTrackerName: str, trackers: List[Dict[str, Any]]
-) -> Optional[int]:
- """Resolve the configured root tracker name to a tracker id.
-
- Strict: case-insensitive exact match. Returns ``None`` if not found
- (the UI must surface this as a config error).
- """
- target = (rootTrackerName or "").strip().lower()
- if not target:
- return None
- for t in trackers:
- if str(t.get("name") or "").strip().lower() == target:
- tid = t.get("id")
- return int(tid) if tid is not None else None
- return None
-
-
-def _schemaFromCache(
- projectId: str, cache: Optional[Dict[str, Any]], rootTrackerName: str
-) -> Optional[RedmineFieldSchemaDto]:
- if not cache:
- return None
- trackers = cache.get("trackers") or []
- return RedmineFieldSchemaDto(
- projectId=projectId,
- projectName=str(cache.get("projectName") or ""),
- trackers=[RedmineFieldChoiceDto(**t) for t in trackers],
- statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []],
- priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []],
- users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []],
- categories=[RedmineFieldChoiceDto(**c) for c in cache.get("categories") or []],
- customFields=[
- RedmineCustomFieldSchemaDto(
- id=cf.get("id"),
- name=cf.get("name", ""),
- fieldFormat=cf.get("fieldFormat", "string"),
- isRequired=bool(cf.get("isRequired")),
- possibleValues=list(cf.get("possibleValues") or []),
- multiple=bool(cf.get("multiple")),
- defaultValue=cf.get("defaultValue"),
- )
- for cf in cache.get("customFields") or []
- if cf.get("id") is not None
- ],
- rootTrackerName=rootTrackerName,
- rootTrackerId=_resolveRootTrackerId(rootTrackerName, trackers),
- )
-
# ---------------------------------------------------------------------------
# Mirror -> RedmineTicketDto
diff --git a/modules/features/redmine/serviceRedmineStats.py b/modules/features/redmine/serviceRedmineStats.py
index 1c289181..8566db16 100644
--- a/modules/features/redmine/serviceRedmineStats.py
+++ b/modules/features/redmine/serviceRedmineStats.py
@@ -83,10 +83,8 @@ async def getStats(
# Lazy import: keeps the pure aggregation helpers below importable
# without dragging in aiohttp / DB connector at module load.
- from modules.features.redmine.serviceRedmine import (
- getProjectMeta,
- listTickets,
- )
+ from modules.features.redmine.interfaceFeatureRedmine import getProjectMeta
+ from modules.features.redmine.serviceRedmine import listTickets
schema = await getProjectMeta(currentUser, mandateId, featureInstanceId)
root_tracker_id = schema.rootTrackerId
diff --git a/modules/features/redmine/serviceRedmineSync.py b/modules/features/redmine/serviceRedmineSync.py
index 37507973..a56198f1 100644
--- a/modules/features/redmine/serviceRedmineSync.py
+++ b/modules/features/redmine/serviceRedmineSync.py
@@ -38,7 +38,7 @@ from modules.features.redmine.datamodelRedmine import (
RedmineSyncStatusDto,
RedmineTicketMirror,
)
-from modules.features.redmine.interfaceFeatureRedmine import getInterface
+from modules.features.redmine.interfaceFeatureRedmine import getInterface, getProjectMeta
from modules.features.redmine.serviceRedmineStatsCache import getStatsCache
logger = logging.getLogger(__name__)
@@ -281,8 +281,6 @@ async def _ensureSchemaWarm(
statuses = (cfg.schemaCache or {}).get("statuses") or []
if statuses:
return
- # Lazy import to avoid a circular dependency at module load.
- from modules.features.redmine.serviceRedmine import getProjectMeta
try:
await getProjectMeta(currentUser, mandateId, featureInstanceId, forceRefresh=True)
except Exception as e:
diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py
index 2bafd0e2..2487ad81 100644
--- a/modules/features/teamsbot/service.py
+++ b/modules/features/teamsbot/service.py
@@ -405,9 +405,9 @@ def createAiService(user, mandateId, featureInstanceId=None):
"""Create a properly wired AiService via the service center."""
ctx = ServiceCenterContext(
user=user,
- mandate_id=mandateId,
- feature_instance_id=featureInstanceId,
- feature_code="teamsbot",
+ mandateId=mandateId,
+ featureInstanceId=featureInstanceId,
+ featureCode="teamsbot",
)
return _getServiceCenterService("ai", ctx)
@@ -1320,9 +1320,9 @@ class TeamsbotService:
ctx = ServiceCenterContext(
user=self.currentUser,
- mandate_id=self.mandateId,
- feature_instance_id=self.instanceId,
- feature_code="teamsbot",
+ mandateId=self.mandateId,
+ featureInstanceId=self.instanceId,
+ featureCode="teamsbot",
)
agentService = _getServiceCenterService("agent", ctx)
diff --git a/modules/features/teamsbot/serviceCommands.py b/modules/features/teamsbot/serviceCommands.py
index 55f16bf0..a8ce763f 100644
--- a/modules/features/teamsbot/serviceCommands.py
+++ b/modules/features/teamsbot/serviceCommands.py
@@ -247,8 +247,8 @@ async def _cmdSendMail(service, sessionId: str, params: dict):
from modules.serviceCenter import ServiceCenterContext, getService
ctx = ServiceCenterContext(
user=service.currentUser,
- mandate_id=service.mandateId,
- feature_instance_id=service.instanceId,
+ mandateId=service.mandateId,
+ featureInstanceId=service.instanceId,
)
messaging = getService("messaging", ctx)
success = messaging.sendEmailDirect(
@@ -280,8 +280,8 @@ async def _cmdStoreDocument(service, sessionId: str, params: dict):
from modules.serviceCenter import ServiceCenterContext, getService
ctx = ServiceCenterContext(
user=service.currentUser,
- mandate_id=service.mandateId,
- feature_instance_id=service.instanceId,
+ mandateId=service.mandateId,
+ featureInstanceId=service.instanceId,
)
sharepoint = getService("sharepoint", ctx)
if not sharepoint.setAccessTokenFromConnection(service.currentUser):
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index 6d83e234..fedda841 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -566,10 +566,10 @@ async def streamWorkspaceStart(
wsBillingFeatureCode = _workspaceBillingFeatureCode(context.user, mandateId or "", instanceId)
svcCtx = ServiceCenterContext(
user=context.user,
- mandate_id=mandateId or "",
- feature_instance_id=instanceId,
- workflow_id=workflowId,
- feature_code=wsBillingFeatureCode,
+ mandateId=mandateId or "",
+ featureInstanceId=instanceId,
+ workflowId=workflowId,
+ featureCode=wsBillingFeatureCode,
)
chatSvc = getService("chat", svcCtx)
attachmentLabel = _buildWorkspaceAttachmentLabel(
@@ -687,10 +687,10 @@ async def _runWorkspaceAgent(
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=user,
- mandate_id=mandateId,
- feature_instance_id=instanceId,
- workflow_id=workflowId,
- feature_code=billingFeatureCode,
+ mandateId=mandateId,
+ featureInstanceId=instanceId,
+ workflowId=workflowId,
+ featureCode=billingFeatureCode,
)
agentService = getService("agent", ctx)
chatService = getService("chat", ctx)
@@ -1299,7 +1299,7 @@ async def listWorkspaceDataSources(
try:
from modules.datamodels.datamodelDataSource import DataSource
from modules.interfaces.interfaceDbApp import getRootInterface
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByConnection
+ from modules.serviceCenter.core.flagResolution import buildEffectiveByConnection
rootIf = getRootInterface()
recordFilter: dict = {"featureInstanceId": instanceId}
if wsMandateId:
@@ -1352,8 +1352,8 @@ async def createWorkspaceDataSource(
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=_mandateId or "",
- feature_instance_id=instanceId,
+ mandateId=_mandateId or "",
+ featureInstanceId=instanceId,
)
chatService = getService("chat", ctx)
dataSource = chatService.createDataSource(
@@ -1381,8 +1381,8 @@ async def deleteWorkspaceDataSource(
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=_mandateId or "",
- feature_instance_id=instanceId,
+ mandateId=_mandateId or "",
+ featureInstanceId=instanceId,
)
chatService = getService("chat", ctx)
chatService.deleteDataSource(dataSourceId)
@@ -1464,7 +1464,7 @@ async def listFeatureDataSources(
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatures import FeatureDataSource
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds
+ from modules.serviceCenter.core.flagResolution import buildEffectiveByWorkspaceFds
rootIf = getRootInterface()
recordFilter: dict = {}
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 4764cd4a..19ff4e26 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -11,7 +11,6 @@ Multi-Tenant Design:
"""
import logging
-import uuid
from typing import Optional, Dict
from passlib.context import CryptContext
from modules.connectors.connectorDbPostgre import DatabaseConnector
@@ -521,6 +520,8 @@ def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None:
Ensure all existing mandates have system-instance roles.
Serves as both initial setup and migration for existing mandates.
"""
+ from modules.interfaces.interfaceRbac import copySystemRolesToMandate
+
allMandates = db.getRecordset(Mandate)
if not allMandates:
logger.info("No mandates found, skipping system role copy")
@@ -534,94 +535,6 @@ def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None:
logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}")
-def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
- """
- Copy system template roles (mandateId=None, isSystemRole=True) to a mandate
- as mandate-instance roles. Also copies all AccessRules for each role.
-
- This is analogous to how feature template roles are copied to feature instances.
- Each mandate gets its own instances of admin/user/viewer with their AccessRules.
-
- Args:
- db: Database connector instance
- mandateId: Target mandate ID
-
- Returns:
- Number of roles copied
- """
- # Find system template roles (global: mandateId=NULL, isSystemRole=True)
- templateRoles = db.getRecordset(
- Role,
- recordFilter={"isSystemRole": True, "mandateId": None}
- )
-
- if not templateRoles:
- logger.warning(f"No system template roles found (mandateId IS NULL, isSystemRole=True)")
- return 0
-
- # Check which mandate-level roles already exist for this mandate
- existingMandateRoles = db.getRecordset(
- Role,
- recordFilter={"mandateId": mandateId, "featureInstanceId": None}
- )
- existingLabels = {r.get("roleLabel") for r in existingMandateRoles}
- logger.info(f"copySystemRolesToMandate: mandate={mandateId}, templates={len(templateRoles)}, existing={len(existingMandateRoles)}, labels={existingLabels}")
-
- # Load all AccessRules for template roles
- templateRoleIds = [r.get("id") for r in templateRoles]
- rulesByRoleId = {}
- for roleId in templateRoleIds:
- rules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
- rulesByRoleId[roleId] = rules
-
- copiedCount = 0
- for templateRole in templateRoles:
- roleLabel = templateRole.get("roleLabel")
-
- # Skip if mandate already has this role
- if roleLabel in existingLabels:
- logger.debug(f"Mandate {mandateId} already has role '{roleLabel}', skipping")
- continue
-
- newRoleId = str(uuid.uuid4())
-
- # Create mandate-instance role
- newRole = Role(
- id=newRoleId,
- roleLabel=roleLabel,
- description=coerce_text_multilingual(templateRole.get("description", {})),
- mandateId=mandateId,
- featureInstanceId=None,
- featureCode=None,
- isSystemRole=False # Mandate-level role, not a system template
- )
- db.recordCreate(Role, newRole.model_dump())
-
- # Copy AccessRules
- templateRules = rulesByRoleId.get(templateRole.get("id"), [])
- for rule in templateRules:
- newRule = AccessRule(
- id=str(uuid.uuid4()),
- roleId=newRoleId,
- context=rule.get("context"),
- item=rule.get("item"),
- view=rule.get("view", False),
- read=rule.get("read"),
- create=rule.get("create"),
- update=rule.get("update"),
- delete=rule.get("delete")
- )
- db.recordCreate(AccessRule, newRule.model_dump())
-
- copiedCount += 1
- logger.info(f"Copied system role '{roleLabel}' to mandate {mandateId} with {len(templateRules)} AccessRules")
-
- if copiedCount > 0:
- logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}")
-
- return copiedCount
-
-
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
"""
Get role ID by label, using cache or database lookup.
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index d5fb2e49..13c7ead6 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1560,7 +1560,7 @@ class AppObjects:
# Copy system template roles to new mandate (admin, user, viewer + AccessRules)
try:
- from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
+ from modules.interfaces.interfaceRbac import copySystemRolesToMandate
copiedCount = copySystemRolesToMandate(self.db, mandateId)
logger.info(f"Copied {copiedCount} system roles to new mandate {mandateId}")
except Exception as e:
@@ -1577,7 +1577,7 @@ class AppObjects:
"""
from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
from modules.datamodels.datamodelFeatures import FeatureInstance
- from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
+ from modules.interfaces.interfaceRbac import copySystemRolesToMandate
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.shared.featureDiscovery import loadFeatureMainModules
plan = BUILTIN_PLANS.get(planKey)
@@ -1615,7 +1615,7 @@ class AppObjects:
raise ValueError(f"No admin role found for mandate {mandateId} — cannot assign user without role")
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
- from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot
+ from modules.shared.systemComponentRegistry import getLifecycleHooks as _getHooks
now = datetime.now(timezone.utc)
nowTs = now.timestamp()
@@ -1635,17 +1635,11 @@ class AppObjects:
subInterface = _getSubRoot()
subInterface.createSubscription(subscription)
- try:
- billingRoot = _getBillingRoot()
- billingRoot.getOrCreateSettings(mandateId)
- billingRoot.ensureActivationBudget(mandateId, planKey)
- except Exception as billingEx:
- logger.error(
- "Initial billing setup failed for mandate %s (plan=%s): %s",
- mandateId,
- planKey,
- billingEx,
- )
+ for _hook in _getHooks("onMandateProvision"):
+ try:
+ _hook(mandateId, planKey)
+ except Exception as _hookErr:
+ logger.error("onMandateProvision hook failed: %s", _hookErr)
self.createUserMandate(userId, mandateId, roleIds=[adminRoleId], skipCapacityCheck=True)
@@ -1865,7 +1859,6 @@ class AppObjects:
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk
from modules.datamodels.datamodelFeatures import FeatureDataSource
- from modules.datamodels.datamodelBilling import BillingSettings, BillingAccount, BillingTransaction
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
@@ -1987,20 +1980,7 @@ class AppObjects:
subInterface.db.recordDelete(MandateSubscription, subId)
logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}")
- # 3b. Delete Billing data (poweron_billing)
- from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot
- billingDb = _getBillingRoot().db
- billingAccounts = billingDb.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId})
- for acc in billingAccounts:
- accTxs = billingDb.getRecordset(BillingTransaction, recordFilter={"accountId": acc.get("id")})
- for tx in accTxs:
- billingDb.recordDelete(BillingTransaction, tx.get("id"))
- billingDb.recordDelete(BillingAccount, acc.get("id"))
- billingSettings = billingDb.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId})
- for bs in billingSettings:
- billingDb.recordDelete(BillingSettings, bs.get("id"))
- if billingAccounts or billingSettings:
- logger.info(f"Cascade: deleted billing data for mandate {mandateId}")
+ # 3b. Billing data cascade handled by onMandateDelete lifecycle hook (interfaceDbBilling)
# 3c. Delete Invitations for this mandate
from modules.datamodels.datamodelInvitation import Invitation
@@ -2155,10 +2135,20 @@ class AppObjects:
)
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
- self._ensureUserBillingAccount(userId, mandateId)
+ from modules.shared.systemComponentRegistry import getLifecycleHooks
+ for _hook in getLifecycleHooks("onUserMandateCreate"):
+ try:
+ _hook(userId, mandateId)
+ except Exception as _hookErr:
+ logger.warning("onUserMandateCreate hook failed: %s", _hookErr)
+
self._syncSubscriptionQuantity(mandateId)
if not skipCapacityCheck:
- self._adjustAiBudgetForUserChange(mandateId, delta=+1)
+ for _hook in getLifecycleHooks("onUserBudgetAdjust"):
+ try:
+ _hook(mandateId, +1)
+ except Exception as _hookErr:
+ logger.warning("onUserBudgetAdjust hook failed: %s", _hookErr)
cleanedRecord = dict(createdRecord)
return UserMandate(**cleanedRecord)
@@ -2167,26 +2157,6 @@ class AppObjects:
raise
logger.error(f"Error creating UserMandate: {e}")
raise ValueError(f"Failed to create UserMandate: {e}") from e
-
- def _ensureUserBillingAccount(self, userId: str, mandateId: str) -> None:
- """
- Ensure a user has a billing audit account for the mandate.
- Balance is always on the mandate pool (PREPAY_MANDATE). User accounts are for audit trail only.
- """
- try:
- from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRootInterface
-
- billingInterface = getBillingRootInterface()
- settings = billingInterface.getSettings(mandateId)
-
- if not settings:
- return
-
- billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
- logger.info(f"Ensured billing audit account for user {userId} in mandate {mandateId}")
-
- except Exception as e:
- logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")
def _checkSubscriptionCapacity(self, mandateId: str, resourceType: str, delta: int = 1) -> None:
"""Check subscription capacity before creating a resource. Raises on cap violation."""
@@ -2222,23 +2192,6 @@ class AppObjects:
raise
logger.debug(f"Subscription quantity sync skipped: {e}")
- def _adjustAiBudgetForUserChange(self, mandateId: str, delta: int) -> None:
- """Pro-rata AI budget credit/debit when a user is added or removed mid-cycle."""
- try:
- from modules.interfaces.interfaceDbSubscription import getInterface as getSubInterface
- from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
- from modules.security.rootAccess import getRootUser
- rootUser = getRootUser()
- subIf = getSubInterface(rootUser, mandateId)
- operative = subIf.getOperativeForMandate(mandateId)
- if not operative:
- return
- planKey = operative.get("planKey", "")
- billingIf = getBillingInterface(rootUser)
- billingIf.adjustAiBudgetForUserChange(mandateId, planKey, delta)
- except Exception as e:
- logger.debug(f"AI budget adjustment skipped: {e}")
-
def deleteUserMandate(self, userId: str, mandateId: str) -> bool:
"""
Delete a UserMandate record (remove user from mandate).
@@ -2278,7 +2231,14 @@ class AppObjects:
result = self.db.recordDelete(UserMandate, existing.id)
self._syncSubscriptionQuantity(mandateId)
- self._adjustAiBudgetForUserChange(mandateId, delta=-1)
+
+ from modules.shared.systemComponentRegistry import getLifecycleHooks
+ for _hook in getLifecycleHooks("onUserMandateDelete"):
+ try:
+ _hook(userId, mandateId)
+ except Exception as _hookErr:
+ logger.warning("onUserMandateDelete hook failed: %s", _hookErr)
+
return result
except Exception as e:
logger.error(f"Error deleting UserMandate: {e}")
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index d51813d8..84cc748e 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -2144,3 +2144,83 @@ class BillingObjects:
# Sort by creation date descending and limit
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getUserTransactionsForMandates")
return allTransactions[:limit]
+
+ def deleteMandateData(self, mandateId: str) -> None:
+ """Delete all billing data for a mandate (accounts, transactions, settings).
+
+ Used as cascade during mandate hard-delete via the onMandateDelete lifecycle hook.
+ """
+ billingAccounts = self.db.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId})
+ for acc in billingAccounts:
+ accTxs = self.db.getRecordset(BillingTransaction, recordFilter={"accountId": acc.get("id")})
+ for tx in accTxs:
+ self.db.recordDelete(BillingTransaction, tx.get("id"))
+ self.db.recordDelete(BillingAccount, acc.get("id"))
+ billingSettings = self.db.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId})
+ for bs in billingSettings:
+ self.db.recordDelete(BillingSettings, bs.get("id"))
+ if billingAccounts or billingSettings:
+ logger.info("deleteMandateData: deleted billing data for mandate %s", mandateId)
+
+
+def onMandateDelete(mandateId: str, instances: list) -> None:
+ """Lifecycle hook: cascade-delete billing data when a mandate is hard-deleted."""
+ getRootInterface().deleteMandateData(mandateId)
+
+
+def onUserMandateCreate(userId: str, mandateId: str) -> None:
+ """Lifecycle hook: ensure user has a billing audit account when added to a mandate."""
+ try:
+ billingInterface = getRootInterface()
+ settings = billingInterface.getSettings(mandateId)
+ if not settings:
+ return
+ billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
+ logger.info("Ensured billing audit account for user %s in mandate %s", userId, mandateId)
+ except Exception as e:
+ logger.warning("Failed to create billing account for user %s (non-critical): %s", userId, e)
+
+
+def onUserMandateDelete(userId: str, mandateId: str) -> None:
+ """Lifecycle hook: pro-rata AI budget debit when user is removed from a mandate."""
+ _adjustAiBudgetForUserChange(mandateId, delta=-1)
+
+
+def onUserBudgetAdjust(mandateId: str, delta: int) -> None:
+ """Lifecycle hook: pro-rata AI budget credit/debit for user membership changes."""
+ _adjustAiBudgetForUserChange(mandateId, delta)
+
+
+def onMandateProvision(mandateId: str, planKey: str) -> None:
+ """Lifecycle hook: create billing settings and activation budget for a new mandate."""
+ try:
+ billingRoot = getRootInterface()
+ billingRoot.getOrCreateSettings(mandateId)
+ billingRoot.ensureActivationBudget(mandateId, planKey)
+ except Exception as e:
+ logger.error("Initial billing setup failed for mandate %s (plan=%s): %s", mandateId, planKey, e)
+
+
+def onStorageChanged(mandateId: str) -> None:
+ """Lifecycle hook: reconcile storage billing after knowledge content changes."""
+ try:
+ getRootInterface().reconcileMandateStorageBilling(mandateId)
+ except Exception as e:
+ logger.warning("reconcileMandateStorageBilling failed for mandate %s: %s", mandateId, e)
+
+
+def _adjustAiBudgetForUserChange(mandateId: str, delta: int) -> None:
+ """Pro-rata AI budget credit/debit when a user is added or removed mid-cycle."""
+ try:
+ from modules.interfaces.interfaceDbSubscription import getInterface as getSubInterface
+ from modules.security.rootAccess import getRootUser
+ rootUser = getRootUser()
+ subIf = getSubInterface(rootUser, mandateId)
+ operative = subIf.getOperativeForMandate(mandateId)
+ if not operative:
+ return
+ planKey = operative.get("planKey", "")
+ billingIf = getInterface(rootUser)
+ billingIf.adjustAiBudgetForUserChange(mandateId, planKey, delta)
+ except Exception as e:
+ logger.debug("AI budget adjustment skipped: %s", e)
diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py
index e979bbd3..20d66dc2 100644
--- a/modules/interfaces/interfaceDbKnowledge.py
+++ b/modules/interfaces/interfaceDbKnowledge.py
@@ -123,13 +123,13 @@ class KnowledgeObjects:
if mid:
mandateIds.add(str(mid))
+ from modules.shared.systemComponentRegistry import getLifecycleHooks
for mid in mandateIds:
- try:
- from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRoot
-
- getBillingRoot().reconcileMandateStorageBilling(mid)
- except Exception as ex:
- logger.warning("reconcileMandateStorageBilling after connection purge failed: %s", ex)
+ for _hook in getLifecycleHooks("onStorageChanged"):
+ try:
+ _hook(mid)
+ except Exception as ex:
+ logger.warning("onStorageChanged hook after connection purge failed: %s", ex)
return {"indexRows": indexCount, "chunks": chunkCount}
@@ -166,12 +166,13 @@ class KnowledgeObjects:
if mid:
mandateIds.add(str(mid))
+ from modules.shared.systemComponentRegistry import getLifecycleHooks
for mid in mandateIds:
- try:
- from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRoot
- getBillingRoot().reconcileMandateStorageBilling(mid)
- except Exception as ex:
- logger.warning("reconcileMandateStorageBilling after datasource purge failed: %s", ex)
+ for _hook in getLifecycleHooks("onStorageChanged"):
+ try:
+ _hook(mid)
+ except Exception as ex:
+ logger.warning("onStorageChanged hook after datasource purge failed: %s", ex)
return {"indexRows": indexCount, "chunks": chunkCount}
@@ -196,12 +197,12 @@ class KnowledgeObjects:
self.db.recordDelete(ContentChunk, chunk["id"])
ok = self.db.recordDelete(FileContentIndex, fileId)
if ok and mandateId:
- try:
- from modules.interfaces.interfaceDbBilling import getRootInterface
-
- getRootInterface().reconcileMandateStorageBilling(str(mandateId))
- except Exception as ex:
- logger.warning("reconcileMandateStorageBilling after delete failed: %s", ex)
+ from modules.shared.systemComponentRegistry import getLifecycleHooks
+ for _hook in getLifecycleHooks("onStorageChanged"):
+ try:
+ _hook(str(mandateId))
+ except Exception as ex:
+ logger.warning("onStorageChanged hook after delete failed: %s", ex)
return ok
# =========================================================================
diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py
index 16429acb..5c09942a 100644
--- a/modules/interfaces/interfaceRbac.py
+++ b/modules/interfaces/interfaceRbac.py
@@ -27,12 +27,15 @@ import json
import math
import re
import copy
+import uuid
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Type, Union
from pydantic import BaseModel
-from modules.datamodels.datamodelRbac import AccessRuleContext
+from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
+from modules.datamodels.datamodelUtils import coerce_text_multilingual
+from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.security.rbac import RbacClass
from modules.security.rootAccess import getRootDbAppConnector
@@ -1123,3 +1126,96 @@ def _checkRowPermission(
# Unknown level - deny by default
return False
+
+
+# =============================================================================
+# System Role Provisioning
+# =============================================================================
+
+
+def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
+ """
+ Copy system template roles (mandateId=None, isSystemRole=True) to a mandate
+ as mandate-instance roles. Also copies all AccessRules for each role.
+
+ This is analogous to how feature template roles are copied to feature instances.
+ Each mandate gets its own instances of admin/user/viewer with their AccessRules.
+
+ Args:
+ db: Database connector instance
+ mandateId: Target mandate ID
+
+ Returns:
+ Number of roles copied
+ """
+ templateRoles = db.getRecordset(
+ Role,
+ recordFilter={"isSystemRole": True, "mandateId": None}
+ )
+
+ if not templateRoles:
+ logger.warning("No system template roles found (mandateId IS NULL, isSystemRole=True)")
+ return 0
+
+ existingMandateRoles = db.getRecordset(
+ Role,
+ recordFilter={"mandateId": mandateId, "featureInstanceId": None}
+ )
+ existingLabels = {r.get("roleLabel") for r in existingMandateRoles}
+ logger.info(
+ "copySystemRolesToMandate: mandate=%s, templates=%s, existing=%s, labels=%s",
+ mandateId, len(templateRoles), len(existingMandateRoles), existingLabels,
+ )
+
+ templateRoleIds = [r.get("id") for r in templateRoles]
+ rulesByRoleId = {}
+ for roleId in templateRoleIds:
+ rules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
+ rulesByRoleId[roleId] = rules
+
+ copiedCount = 0
+ for templateRole in templateRoles:
+ roleLabel = templateRole.get("roleLabel")
+
+ if roleLabel in existingLabels:
+ logger.debug("Mandate %s already has role '%s', skipping", mandateId, roleLabel)
+ continue
+
+ newRoleId = str(uuid.uuid4())
+
+ newRole = Role(
+ id=newRoleId,
+ roleLabel=roleLabel,
+ description=coerce_text_multilingual(templateRole.get("description", {})),
+ mandateId=mandateId,
+ featureInstanceId=None,
+ featureCode=None,
+ isSystemRole=False,
+ )
+ db.recordCreate(Role, newRole.model_dump())
+
+ templateRules = rulesByRoleId.get(templateRole.get("id"), [])
+ for rule in templateRules:
+ newRule = AccessRule(
+ id=str(uuid.uuid4()),
+ roleId=newRoleId,
+ context=rule.get("context"),
+ item=rule.get("item"),
+ view=rule.get("view", False),
+ read=rule.get("read"),
+ create=rule.get("create"),
+ update=rule.get("update"),
+ delete=rule.get("delete"),
+ )
+ db.recordCreate(AccessRule, newRule.model_dump())
+
+ copiedCount += 1
+ logger.info(
+ "Copied system role '%s' to mandate %s with %s AccessRules",
+ roleLabel, mandateId, len(templateRules),
+ )
+
+ if copiedCount > 0:
+ logger.info("Copied %s system roles to mandate %s", copiedCount, mandateId)
+
+ return copiedCount
diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py
index 5de77a9b..7a327d16 100644
--- a/modules/routes/routeDataConnections.py
+++ b/modules/routes/routeDataConnections.py
@@ -798,7 +798,7 @@ async def _updateKnowledgeConsent(
cancelled = cancelJobsByConnection(connectionId)
else:
from modules.datamodels.datamodelDataSource import DataSource
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
+ from modules.serviceCenter.core.flagResolution import getEffectiveFlag
allConnDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
dataSources = [
ds for ds in (allConnDs or [])
diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py
index 52a4b98a..a7a4e34b 100644
--- a/modules/routes/routeDataFiles.py
+++ b/modules/routes/routeDataFiles.py
@@ -98,17 +98,17 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man
return
file_meta = mgmtInterface.getFile(fileId)
- feature_instance_id = ""
- mandate_id = ""
+ featureInstanceId = ""
+ mandateId = ""
file_scope = "personal"
if file_meta:
if isinstance(file_meta, dict):
- feature_instance_id = file_meta.get("featureInstanceId") or ""
- mandate_id = file_meta.get("mandateId") or ""
+ featureInstanceId = file_meta.get("featureInstanceId") or ""
+ mandateId = file_meta.get("mandateId") or ""
file_scope = file_meta.get("scope") or "personal"
else:
- feature_instance_id = getattr(file_meta, "featureInstanceId", None) or ""
- mandate_id = getattr(file_meta, "mandateId", None) or ""
+ featureInstanceId = getattr(file_meta, "featureInstanceId", None) or ""
+ mandateId = getattr(file_meta, "mandateId", None) or ""
file_scope = getattr(file_meta, "scope", None) or "personal"
logger.info(f"Auto-index starting for {fileName} ({len(rawBytes)} bytes, {mimeType})")
@@ -121,8 +121,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man
fileId=fileId,
fileName=fileName,
userId=userId,
- featureInstanceId=str(feature_instance_id) if feature_instance_id else "",
- mandateId=str(mandate_id) if mandate_id else "",
+ featureInstanceId=str(featureInstanceId) if featureInstanceId else "",
+ mandateId=str(mandateId) if mandateId else "",
scope=file_scope,
)
logger.info(
@@ -208,8 +208,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man
ctx = ServiceCenterContext(
user=user,
- mandate_id=str(mandate_id) if mandate_id else "",
- feature_instance_id=str(feature_instance_id) if feature_instance_id else "",
+ mandateId=str(mandateId) if mandateId else "",
+ featureInstanceId=str(featureInstanceId) if featureInstanceId else "",
)
knowledgeService = getService("knowledge", ctx)
@@ -222,8 +222,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man
fileName=fileName,
mimeType=mimeType,
userId=userId,
- featureInstanceId=str(feature_instance_id) if feature_instance_id else "",
- mandateId=str(mandate_id) if mandate_id else "",
+ featureInstanceId=str(featureInstanceId) if featureInstanceId else "",
+ mandateId=str(mandateId) if mandateId else "",
contentObjects=contentObjects,
structure=contentIndex.structure,
provenance={"lane": "upload", "route": "routeDataFiles._autoIndexFile"},
diff --git a/modules/routes/routeRagInventory.py b/modules/routes/routeRagInventory.py
index f7219c60..419ddec1 100644
--- a/modules/routes/routeRagInventory.py
+++ b/modules/routes/routeRagInventory.py
@@ -86,7 +86,7 @@ def _buildConnectionInventory(connections, rootIf, knowledgeIf, jobService) -> L
"""
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelKnowledge import FileContentIndex
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
+ from modules.serviceCenter.core.flagResolution import getEffectiveFlag
out = []
for conn in connections:
@@ -236,7 +236,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
from modules.datamodels.datamodelKnowledge import FileContentIndex
from modules.datamodels.datamodelFeatures import FeatureDataSource
from modules.interfaces.interfaceFeatures import getFeatureInterface
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
+ from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds
from modules.serviceCenter.services.serviceBackgroundJobs import mainBackgroundJobService as jobService
from modules.serviceCenter.services.serviceKnowledge.subFeatureBootstrap import FEATURE_BOOTSTRAP_JOB_TYPE
@@ -548,7 +548,7 @@ async def _reindexConnection(
if str(conn.userId) != str(currentUser.id):
raise HTTPException(status_code=403, detail="Not your connection")
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
+ from modules.serviceCenter.core.flagResolution import getEffectiveFlag
dataSources = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
ragDs = [ds for ds in dataSources if getEffectiveFlag(ds, "ragIndexEnabled", dataSources, mode="walk") is True]
if not ragDs:
diff --git a/modules/routes/routeVoiceUser.py b/modules/routes/routeVoiceUser.py
index 7ddfbed4..ce14afe0 100644
--- a/modules/routes/routeVoiceUser.py
+++ b/modules/routes/routeVoiceUser.py
@@ -251,7 +251,7 @@ async def _generateTtsSampleTextForLocale(
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException
mandateId = _resolveMandateIdForVoiceTestAi(request, currentUser)
- ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=None)
+ ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId, featureInstanceId=None)
aiService = getService("ai", ctx)
systemPrompt = (
diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py
index 8a9dd587..afd4aaa0 100644
--- a/modules/routes/routeWorkflowAutomation.py
+++ b/modules/routes/routeWorkflowAutomation.py
@@ -596,8 +596,8 @@ def _buildServiceCenterContext(context: RequestContext, mandateId: str, instance
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,
+ mandateId=str(context.mandateId) if context.mandateId else mandateId,
+ featureInstanceId=instanceId,
)
@@ -1366,6 +1366,21 @@ def _buildExecuteRunEnvelope(
return env
+def _startEmailPollerIfNeeded(result: dict) -> None:
+ """Start the background email poller when a run pauses for email wait."""
+ if not isinstance(result, dict) or result.get("waitReason") != "email":
+ return
+ try:
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ from modules.workflowAutomation.scheduler.emailPoller import ensureRunning
+ root = getRootInterface()
+ eventUser = root.getUserByUsername("event") if root else None
+ if eventUser:
+ ensureRunning(eventUser)
+ except Exception as pollErr:
+ logger.warning("Could not start email poller: %s", pollErr)
+
+
@router.post("/workflows/{workflowId}/execute")
@limiter.limit("30/minute")
async def _executeWorkflow(
@@ -1446,6 +1461,7 @@ async def _executeWorkflow(
"workflowAutomation execute result: success=%s error=%s paused=%s",
result.get("success"), result.get("error"), result.get("paused"),
)
+ _startEmailPollerIfNeeded(result)
return result
@@ -1778,7 +1794,7 @@ async def _completeTask(
graph = wfForGraph["graph"]
services = _getWorkflowAutomationServices(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- return await executeGraph(
+ result = await executeGraph(
graph=graph,
services=services,
workflowId=workflowId,
@@ -1790,6 +1806,8 @@ async def _completeTask(
startAfterNodeId=taskNodeId,
runId=runId,
)
+ _startEmailPollerIfNeeded(result)
+ return result
@router.post("/tasks/{taskId}/cancel")
diff --git a/modules/serviceCenter/__init__.py b/modules/serviceCenter/__init__.py
index a2590fc6..968b8acf 100644
--- a/modules/serviceCenter/__init__.py
+++ b/modules/serviceCenter/__init__.py
@@ -33,7 +33,7 @@ def getService(
Args:
key: Service key (e.g., "web", "extraction", "utils")
- context: ServiceCenterContext with user, mandate_id, feature_instance_id, workflow
+ context: ServiceCenterContext with user, mandateId, featureInstanceId, workflow
Returns:
Service instance
diff --git a/modules/serviceCenter/context.py b/modules/serviceCenter/context.py
index 24868fca..2738f8b3 100644
--- a/modules/serviceCenter/context.py
+++ b/modules/serviceCenter/context.py
@@ -16,20 +16,10 @@ class ServiceCenterContext:
"""Context for service resolution: user, mandate, feature instance, optional workflow."""
user: User
- mandate_id: Optional[str] = None
- feature_instance_id: Optional[str] = None
- workflow_id: Optional[str] = None
+ mandateId: Optional[str] = None
+ featureInstanceId: Optional[str] = None
+ workflowId: Optional[str] = None
workflow: Any = None
requireNeutralization: Optional[bool] = None
# When workflow is absent (e.g. workspace agent), billing/UI still need feature code for transactions.
- feature_code: Optional[str] = None
-
- @property
- def mandateId(self) -> Optional[str]:
- """Alias for mandate_id (backward compatibility)."""
- return self.mandate_id
-
- @property
- def featureInstanceId(self) -> Optional[str]:
- """Alias for feature_instance_id (backward compatibility)."""
- return self.feature_instance_id
+ featureCode: Optional[str] = None
diff --git a/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py b/modules/serviceCenter/core/flagResolution.py
similarity index 100%
rename from modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py
rename to modules/serviceCenter/core/flagResolution.py
diff --git a/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py b/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py
index 4591c36e..b5a9a84b 100644
--- a/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py
+++ b/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py
@@ -20,12 +20,12 @@ class SecurityService:
def __init__(self, context: Any, get_service: Callable[[str], Any]):
"""Initialize with service center context and resolver."""
self._context = context
- self._get_service = get_service
+ self._getService = get_service
self._tokenManager = TokenManager()
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
self._interfaceDbApp = getAppInterface(
context.user,
- mandateId=context.mandate_id,
+ mandateId=context.mandateId,
)
def getFreshToken(self, connectionId: str, secondsBeforeExpiry: int = 30 * 60) -> Optional[Token]:
diff --git a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py
index c6c7ddf7..76369553 100644
--- a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py
+++ b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py
@@ -19,7 +19,7 @@ class StreamingService:
def __init__(self, context: Any, get_service: Callable[[str], Any]):
"""Initialize with service center context and resolver."""
self._context = context
- self._get_service = get_service
+ self._getService = get_service
def getEventManager(self) -> EventManager:
"""Get the global event manager instance for SSE streaming."""
diff --git a/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py b/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py
index d22eab1b..856514bf 100644
--- a/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py
+++ b/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py
@@ -22,7 +22,7 @@ class UtilsService:
def __init__(self, context, get_service: Callable[[str], Any]):
"""Initialize with service center context and resolver."""
self._context = context
- self._get_service = get_service
+ self._getService = get_service
# ===== Event handling =====
diff --git a/modules/serviceCenter/core/types.py b/modules/serviceCenter/core/types.py
new file mode 100644
index 00000000..19c15081
--- /dev/null
+++ b/modules/serviceCenter/core/types.py
@@ -0,0 +1,90 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Neutral protocol types used across serviceCenter services.
+
+Protocols defined here break import cycles by providing structural typing
+contracts that services can depend on without importing concrete classes
+from sibling services.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
+
+
+# ---------------------------------------------------------------------------
+# FeatureDataProviderProtocol (used by serviceKnowledge, implemented in serviceAgent)
+# ---------------------------------------------------------------------------
+
+@runtime_checkable
+class FeatureDataProviderProtocol(Protocol):
+ """Structural contract for the RBAC-scoped feature-data read layer.
+
+ serviceKnowledge depends on this Protocol for RAG indexing;
+ serviceAgent supplies the concrete FeatureDataProvider implementation.
+ """
+
+ def browseTable(
+ self,
+ tableName: str,
+ featureInstanceId: str,
+ mandateId: str,
+ fields: Optional[List[str]] = None,
+ limit: int = 50,
+ offset: int = 0,
+ extraFilters: Optional[List[Dict[str, Any]]] = None,
+ ) -> Dict[str, Any]: ...
+
+ async def finalizeRowsAsync(
+ self,
+ tableName: str,
+ rows: List[Dict[str, Any]],
+ ) -> List[Dict[str, Any]]: ...
+
+
+# ---------------------------------------------------------------------------
+# FeatureDataProvider factory registry
+# ---------------------------------------------------------------------------
+
+_featureDataProviderFactory = None
+
+
+def registerFeatureDataProviderFactory(factory) -> None:
+ """Register the concrete FeatureDataProvider class (called at composition time)."""
+ global _featureDataProviderFactory
+ _featureDataProviderFactory = factory
+
+
+def createFeatureDataProvider(
+ dbConnector,
+ neutralizeFields: Optional[Dict[str, List[str]]] = None,
+ neutralizePolicy: Optional[Dict[str, Dict[str, Any]]] = None,
+ neutralizationService: Optional[Any] = None,
+) -> FeatureDataProviderProtocol:
+ """Instantiate a FeatureDataProvider without importing serviceAgent."""
+ if _featureDataProviderFactory is None:
+ raise RuntimeError(
+ "FeatureDataProvider factory not registered. "
+ "Ensure serviceAgent is initialized before serviceKnowledge bootstrap runs."
+ )
+ return _featureDataProviderFactory(
+ dbConnector,
+ neutralizeFields=neutralizeFields,
+ neutralizePolicy=neutralizePolicy,
+ neutralizationService=neutralizationService,
+ )
+
+
+# ---------------------------------------------------------------------------
+# RendererProtocol (used by serviceExtraction, implemented in serviceGeneration)
+# ---------------------------------------------------------------------------
+
+@runtime_checkable
+class RendererProtocol(Protocol):
+ """Structural contract for document renderers.
+
+ serviceExtraction depends on this Protocol for type hints;
+ serviceGeneration supplies BaseRenderer and its subclasses.
+ """
+
+ def getExtractionGuidelines(self) -> str: ...
diff --git a/modules/serviceCenter/resolver.py b/modules/serviceCenter/resolver.py
index 316ce052..729adb69 100644
--- a/modules/serviceCenter/resolver.py
+++ b/modules/serviceCenter/resolver.py
@@ -19,7 +19,7 @@ GetServiceFunc = Callable[[str], Any]
def _make_context_id(ctx: ServiceCenterContext) -> str:
"""Create a stable cache key from context."""
- return f"{id(ctx.user)}_{ctx.mandate_id or ''}_{ctx.feature_instance_id or ''}"
+ return f"{id(ctx.user)}_{ctx.mandateId or ''}_{ctx.featureInstanceId or ''}"
def _load_service_class(module_path: str, class_name: str):
diff --git a/modules/serviceCenter/services/serviceAgent/__init__.py b/modules/serviceCenter/services/serviceAgent/__init__.py
index 05d5452b..8878ece1 100644
--- a/modules/serviceCenter/services/serviceAgent/__init__.py
+++ b/modules/serviceCenter/services/serviceAgent/__init__.py
@@ -1,3 +1,8 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""serviceAgent: AI Agent with ReAct loop and native function calling."""
+
+from modules.serviceCenter.core.types import registerFeatureDataProviderFactory
+from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider
+
+registerFeatureDataProviderFactory(FeatureDataProvider)
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
index 76fd0bae..f1e49368 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
@@ -151,7 +151,7 @@ def registerDataSourceTools(registry: ToolRegistry, services):
sourceType = ds.get("sourceType", "")
path = ds.get("path", "/")
label = ds.get("label", "")
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
+ from modules.serviceCenter.core.flagResolution import getEffectiveFlag
from modules.datamodels.datamodelDataSource import DataSource
from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
index 2dfc4686..f522a62a 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
@@ -109,7 +109,7 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services):
recordFilter={"featureInstanceId": featureInstanceId},
)
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
+ from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds
_fdsAll = featureDataSources or []
_anySourceNeutralize = any(
getEffectiveFlagFds(ds, "neutralize", _fdsAll, mode="walk") is True
@@ -160,7 +160,7 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services):
# A2: build the per-table type/inheritance-aware neutralization policy.
# tableActive = effective (own or inherited) table-level neutralize flag;
# explicitFields = fields whose neutralize flag is set explicitly.
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import resolveEffectiveForFds
+ from modules.serviceCenter.core.flagResolution import resolveEffectiveForFds
neutralizePolicy: Dict[str, Dict[str, Any]] = {}
for tblObj in selectedTables:
tn = tblObj.get("meta", {}).get("table", "") if isinstance(tblObj, dict) else ""
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index e977f596..390d062c 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -68,8 +68,8 @@ class ServicesBag:
self._context = context
self._getService = getService
self.user = context.user
- self.mandateId = context.mandate_id
- self.featureInstanceId = context.feature_instance_id
+ self.mandateId = context.mandateId
+ self.featureInstanceId = context.featureInstanceId
@property
def workflow(self):
diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
index 0fd10678..98489dc8 100644
--- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py
+++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
@@ -42,10 +42,10 @@ class _ServicesAdapter:
Workflow is read from context dynamically so propagation updates are visible."""
def __init__(self, context, get_service: Callable[[str], Any]):
self._context = context
- self._get_service = get_service
+ self._getService = get_service
self.user = context.user
- self.mandateId = context.mandate_id
- self.featureInstanceId = context.feature_instance_id
+ self.mandateId = context.mandateId
+ self.featureInstanceId = context.featureInstanceId
@property
def workflow(self):
@@ -57,31 +57,31 @@ class _ServicesAdapter:
@property
def chat(self):
- return self._get_service("chat")
+ return self._getService("chat")
@property
def extraction(self):
- return self._get_service("extraction")
+ return self._getService("extraction")
@property
def utils(self):
- return self._get_service("utils")
+ return self._getService("utils")
@property
def ai(self):
- return self._get_service("ai")
+ return self._getService("ai")
@property
def interfaceDbChat(self):
- return self._get_service("chat").interfaceDbChat
+ return self._getService("chat").interfaceDbChat
@property
def interfaceDbComponent(self):
- return self._get_service("chat").interfaceDbComponent
+ return self._getService("chat").interfaceDbComponent
@property
def featureCode(self) -> Optional[str]:
- fc = getattr(self._context, "feature_code", None)
+ fc = getattr(self._context, "featureCode", None)
if fc and str(fc).strip():
return str(fc).strip()
w = self.workflow
@@ -102,11 +102,11 @@ class AiService:
"""Initialize with ServiceCenterContext and service resolver.
Args:
- context: ServiceCenterContext with user, mandate_id, feature_instance_id, workflow
+ context: ServiceCenterContext with user, mandateId, featureInstanceId, workflow
get_service: Callable to resolve dependency services by key
"""
self.services = _ServicesAdapter(context, get_service)
- self._get_service = get_service
+ self._getService = get_service
self.aiObjects = None
self.extractionService = None
@@ -117,7 +117,7 @@ class AiService:
if self.extractionService is None:
logger.info("Initializing ExtractionService via service center...")
- self.extractionService = self._get_service("extraction")
+ self.extractionService = self._getService("extraction")
# Initialize new submodules
from .subResponseParsing import ResponseParser
@@ -673,7 +673,7 @@ detectedIntent-Werte:
_sources = []
# Source 1: Feature-Instance config
- _neutralSvc = self._get_service("neutralization")
+ _neutralSvc = self._getService("neutralization")
if _neutralSvc and hasattr(_neutralSvc, 'getConfig'):
_config = _neutralSvc.getConfig()
if _config and getattr(_config, 'enabled', False):
@@ -721,7 +721,7 @@ detectedIntent-Werte:
_hardMode = request.requireNeutralization is True
excludedDocs: List[str] = []
- neutralSvc = self._get_service("neutralization")
+ neutralSvc = self._getService("neutralization")
if not neutralSvc or not hasattr(neutralSvc, 'processTextAsync'):
if _hardMode:
raise RuntimeError("Neutralization explicitly required but service unavailable — AI call BLOCKED")
@@ -1193,7 +1193,7 @@ detectedIntent-Werte:
contentOut = getattr(response, 'content', None)
contentOutput = str(contentOut) if contentOut else None
- neutralSvc = self._get_service("neutralization") if wasNeutralized else None
+ neutralSvc = self._getService("neutralization") if wasNeutralized else None
mappingsCount = None
if neutralSvc and hasattr(neutralSvc, 'getActiveMappingsCount'):
try:
@@ -1324,8 +1324,8 @@ detectedIntent-Werte:
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=servicesHub.user,
- mandate_id=servicesHub.mandateId,
- feature_instance_id=servicesHub.featureInstanceId,
+ mandateId=servicesHub.mandateId,
+ featureInstanceId=servicesHub.featureInstanceId,
workflow=getattr(servicesHub, "workflow", None),
)
return getService("ai", ctx)
@@ -1721,7 +1721,7 @@ Respond with ONLY a JSON object in this exact format:
)
try:
- generationService = self._get_service("generation")
+ generationService = self._getService("generation")
# renderReport verarbeitet jetzt jedes Dokument einzeln
# und gibt Liste von (documentData, mimeType, filename) zurück
diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
index f2418b4b..2158506f 100644
--- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
+++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
@@ -56,9 +56,9 @@ def getService(currentUser: User, mandateId: str, featureInstanceId: str = None,
return _billingServices[cacheKey]
-def _get_feature_code_from_context(context) -> Optional[str]:
+def _getFeatureCodeFromContext(context) -> Optional[str]:
"""Extract featureCode from ServiceCenterContext."""
- explicit = getattr(context, "feature_code", None)
+ explicit = getattr(context, "featureCode", None)
if explicit and str(explicit).strip():
return str(explicit).strip()
if context.workflow and hasattr(context.workflow, "feature") and context.workflow.feature:
@@ -91,15 +91,15 @@ class BillingService:
ctx = context_or_user
get_service = mandateId
self.currentUser = ctx.user
- self.mandateId = ctx.mandate_id or ""
- self.featureInstanceId = ctx.feature_instance_id
- self.featureCode = _get_feature_code_from_context(ctx)
+ self.mandateId = ctx.mandateId or ""
+ self.featureInstanceId = ctx.featureInstanceId
+ self.featureCode = _getFeatureCodeFromContext(ctx)
elif get_service is not None and hasattr(context_or_user, "user"):
ctx = context_or_user
self.currentUser = ctx.user
- self.mandateId = ctx.mandate_id or ""
- self.featureInstanceId = ctx.feature_instance_id
- self.featureCode = _get_feature_code_from_context(ctx)
+ self.mandateId = ctx.mandateId or ""
+ self.featureInstanceId = ctx.featureInstanceId
+ self.featureCode = _getFeatureCodeFromContext(ctx)
else:
self.currentUser = context_or_user
self.mandateId = mandateId or ""
diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
index 77856a7d..3382f75e 100644
--- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py
+++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
@@ -18,17 +18,17 @@ class ChatService:
def __init__(self, context, get_service: Callable[[str], Any]):
"""Initialize with ServiceCenterContext and service resolver."""
self._context = context
- self._get_service = get_service
+ self._getService = get_service
self.user = context.user
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
- self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandate_id)
- self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandate_id, featureInstanceId=context.feature_instance_id)
+ self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandateId)
+ self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandateId, featureInstanceId=context.featureInstanceId)
self.interfaceDbChat = getChatInterface(
context.user,
- mandateId=context.mandate_id,
- featureInstanceId=context.feature_instance_id,
+ mandateId=context.mandateId,
+ featureInstanceId=context.featureInstanceId,
)
self._progressLogger = None
@@ -374,10 +374,10 @@ class ChatService:
try:
# Get a fresh token via security service
logger.debug(f"Getting fresh token for connection {connection.id}")
- token = self._get_service("security").getFreshToken(connection.id)
+ token = self._getService("security").getFreshToken(connection.id)
if token:
if hasattr(token, 'expiresAt') and token.expiresAt:
- current_time = self._get_service("utils").timestampGetUtc()
+ current_time = self._getService("utils").timestampGetUtc()
if current_time > token.expiresAt:
token_status = "expired"
else:
@@ -462,7 +462,7 @@ class ChatService:
Token object or None if not found/expired
"""
try:
- return self._get_service("security").getFreshToken(connectionId)
+ return self._getService("security").getFreshToken(connectionId)
except Exception as e:
logger.error(f"Error getting fresh token for connection {connectionId}: {str(e)}")
return None
@@ -575,8 +575,8 @@ class ChatService:
path=path,
label=label,
displayPath=displayPath,
- featureInstanceId=featureInstanceId or self._context.feature_instance_id or "",
- mandateId=self._context.mandate_id or "",
+ featureInstanceId=featureInstanceId or self._context.featureInstanceId or "",
+ mandateId=self._context.mandateId or "",
userId=self.user.id if self.user else "",
)
return self.interfaceDbApp.db.recordCreate(DataSource, ds)
diff --git a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py
index d1ef51b3..74a7e809 100644
--- a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py
+++ b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py
@@ -30,7 +30,7 @@ class ClickupService(ClickupApiClient):
def __init__(self, context, get_service: Callable[[str], Any]):
super().__init__(accessToken="")
self._context = context
- self._get_service = get_service
+ self._getService = get_service
def setAccessTokenFromConnection(self, userConnection) -> bool:
"""Load OAuth/personal token from SecurityService for this UserConnection."""
@@ -45,7 +45,7 @@ class ClickupService(ClickupApiClient):
if not connection_id:
logger.error("UserConnection must have an 'id' field")
return False
- security = self._get_service("security")
+ security = self._getService("security")
if not security:
logger.error("Security service not available for token access")
return False
diff --git a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py
index a3fb0baf..125281e7 100644
--- a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py
+++ b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py
@@ -28,12 +28,12 @@ class ExtractionService:
def __init__(self, context, get_service: Callable[[str], Any]):
"""Initialize with ServiceCenterContext and service resolver."""
self._context = context
- self._get_service = get_service
+ self._getService = get_service
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
self._interfaceDbComponent = getComponentInterface(
context.user,
- mandateId=context.mandate_id,
- featureInstanceId=context.feature_instance_id,
+ mandateId=context.mandateId,
+ featureInstanceId=context.featureInstanceId,
)
self._extractorRegistry = getExtractorRegistry()
if ExtractionService._sharedChunkerRegistry is None:
@@ -117,7 +117,7 @@ class ExtractionService:
docOperationId = f"{operationId}_doc_{i}"
# Use parentOperationId if provided, otherwise use operationId as parent
parentId = parentOperationId if parentOperationId else operationId
- self._get_service("chat").progressLogStart(
+ self._getService("chat").progressLogStart(
docOperationId,
"Extracting Document",
f"Document {i + 1}/{totalDocs}",
@@ -130,17 +130,17 @@ class ExtractionService:
try:
if docOperationId:
- self._get_service("chat").progressLogUpdate(docOperationId, 0.1, "Loading document data")
+ self._getService("chat").progressLogUpdate(docOperationId, 0.1, "Loading document data")
# Resolve raw bytes for this document using interface
documentBytes = dbInterface.getFileData(doc.fileId)
if not documentBytes:
if docOperationId:
- self._get_service("chat").progressLogFinish(docOperationId, False)
+ self._getService("chat").progressLogFinish(docOperationId, False)
raise ValueError(f"No file data found for fileId={doc.fileId}")
if docOperationId:
- self._get_service("chat").progressLogUpdate(docOperationId, 0.2, "Running extraction pipeline")
+ self._getService("chat").progressLogUpdate(docOperationId, 0.2, "Running extraction pipeline")
# Convert ChatDocument to the format expected by runExtraction
documentData = {
@@ -160,7 +160,7 @@ class ExtractionService:
)
if docOperationId:
- self._get_service("chat").progressLogUpdate(docOperationId, 0.7, f"Extracted {len(ec.parts)} parts")
+ self._getService("chat").progressLogUpdate(docOperationId, 0.7, f"Extracted {len(ec.parts)} parts")
# Log content parts metadata
logger.debug(f"Content parts: {len(ec.parts)}")
@@ -223,7 +223,7 @@ class ExtractionService:
# Use document name and part index for filename
doc_name_safe = documentData["fileName"].replace(" ", "_").replace("/", "_").replace("\\", "_")[:50]
debug_filename = f"extraction_text_part_{j+1}_{doc_name_safe}.txt"
- self._get_service("utils").writeDebugFile(debug_json, debug_filename)
+ self._getService("utils").writeDebugFile(debug_json, debug_filename)
logger.info(f"Wrote debug file for extracted text part {j+1}/{len(ec.parts)}: {debug_filename}")
except Exception as e:
logger.warning(f"Failed to write debug file for text part {j+1}: {str(e)}")
@@ -240,7 +240,7 @@ class ExtractionService:
logger.debug(f"No chunking needed - {len(ec.parts)} parts fit within size limits")
if docOperationId:
- self._get_service("chat").progressLogUpdate(docOperationId, 0.9, f"Processing complete: {len(ec.parts)} parts extracted")
+ self._getService("chat").progressLogUpdate(docOperationId, 0.9, f"Processing complete: {len(ec.parts)} parts extracted")
# Calculate timing and emit stats
endTime = time.time()
@@ -256,7 +256,7 @@ class ExtractionService:
# Hard fail if model is missing; caller must ensure connectors are registered
if model is None or model.calculatepriceCHF is None:
if docOperationId:
- self._get_service("chat").progressLogFinish(docOperationId, False)
+ self._getService("chat").progressLogFinish(docOperationId, False)
raise RuntimeError(f"Pricing model not available: {modelDisplayName}")
priceCHF = model.calculatepriceCHF(processingTime, bytesSent, bytesReceived)
@@ -309,13 +309,13 @@ class ExtractionService:
# Finish document operation successfully
if docOperationId:
- self._get_service("chat").progressLogFinish(docOperationId, True)
+ self._getService("chat").progressLogFinish(docOperationId, True)
except Exception as e:
logger.error(f"Error extracting content from document {i + 1}/{totalDocs} ({doc.fileName}): {str(e)}")
if docOperationId:
try:
- self._get_service("chat").progressLogFinish(docOperationId, False)
+ self._getService("chat").progressLogFinish(docOperationId, False)
except:
pass # Don't fail on progress logging errors
# Continue with next document instead of failing completely
@@ -355,7 +355,7 @@ class ExtractionService:
if not operationId:
workflowId = self._context.workflow.id if self._context.workflow else f"no-workflow-{int(time.time())}"
operationId = f"ai_text_extract_{workflowId}_{int(time.time())}"
- self._get_service("chat").progressLogStart(
+ self._getService("chat").progressLogStart(
operationId,
"AI Text Extract",
"Document Processing",
@@ -383,19 +383,19 @@ class ExtractionService:
# Extract content WITHOUT chunking
if operationId:
- self._get_service("chat").progressLogUpdate(operationId, 0.1, f"Extracting content from {len(documents)} documents")
+ self._getService("chat").progressLogUpdate(operationId, 0.1, f"Extracting content from {len(documents)} documents")
# Pass operationId as parentOperationId for hierarchical logging
# Correct hierarchy: parentOperationId -> operationId -> docOperationId
extractionResult = self.extractContent(documents, extractionOptions, operationId=operationId, parentOperationId=operationId)
if not isinstance(extractionResult, list):
if operationId:
- self._get_service("chat").progressLogFinish(operationId, False)
+ self._getService("chat").progressLogFinish(operationId, False)
return "[Error: No extraction results]"
# Process parts (not chunks) with model-aware AI calls
if operationId:
- self._get_service("chat").progressLogUpdate(operationId, 0.3, f"Processing {len(extractionResult)} extracted content parts")
+ self._getService("chat").progressLogUpdate(operationId, 0.3, f"Processing {len(extractionResult)} extracted content parts")
# Use operationId as parentOperationId for child operations
# Correct hierarchy: parentOperationId -> operationId -> partOperationId
processParentOperationId = operationId
@@ -403,20 +403,20 @@ class ExtractionService:
# Merge results using existing merging system
if operationId:
- self._get_service("chat").progressLogUpdate(operationId, 0.9, f"Merging {len(partResults)} part results")
+ self._getService("chat").progressLogUpdate(operationId, 0.9, f"Merging {len(partResults)} part results")
mergedContent = self.mergePartResults(partResults, options)
# Save merged extraction content to debug
- self._get_service("utils").writeDebugFile(mergedContent or '', "extraction_merged_text")
+ self._getService("utils").writeDebugFile(mergedContent or '', "extraction_merged_text")
if operationId:
- self._get_service("chat").progressLogFinish(operationId, True)
+ self._getService("chat").progressLogFinish(operationId, True)
return mergedContent
except Exception as e:
logger.error(f"Error in processDocumentsPerChunk: {str(e)}")
if operationId:
- self._get_service("chat").progressLogFinish(operationId, False)
+ self._getService("chat").progressLogFinish(operationId, False)
raise
async def _processPartsWithMapping(
@@ -468,7 +468,7 @@ class ExtractionService:
if operationId:
workflowId = self._context.workflow.id if self._context.workflow else f"no-workflow-{int(time.time())}"
partOperationId = f"{operationId}_part_{part_index}"
- self._get_service("chat").progressLogStart(
+ self._getService("chat").progressLogStart(
partOperationId,
"Content Processing",
f"Part {part_index + 1}",
@@ -487,15 +487,15 @@ class ExtractionService:
# Update progress - initiating
if partOperationId:
- self._get_service("chat").progressLogUpdate(partOperationId, 0.3, "Initiating")
+ self._getService("chat").progressLogUpdate(partOperationId, 0.3, "Initiating")
# Call AI with model-aware chunking (no progress callback - handled by parent operation)
response = await aiObjects.call(request)
# Update progress - completed
if partOperationId:
- self._get_service("chat").progressLogUpdate(partOperationId, 0.9, "Completed")
- self._get_service("chat").progressLogFinish(partOperationId, True)
+ self._getService("chat").progressLogUpdate(partOperationId, 0.9, "Completed")
+ self._getService("chat").progressLogFinish(partOperationId, True)
processing_time = time.time() - start_time
@@ -1133,7 +1133,7 @@ class ExtractionService:
"perPartExtractedData": per_part_extracted_data
}
debug_json = json.dumps(debug_content, indent=2, ensure_ascii=False)
- self._get_service("utils").writeDebugFile(debug_json, "content_extraction_per_part")
+ self._getService("utils").writeDebugFile(debug_json, "content_extraction_per_part")
logger.info(f"Wrote per-part extracted data to debug file: {len(per_part_extracted_data)} blocks from {len(content_parts)} content parts")
except Exception as e:
logger.warning(f"Failed to write per-part extracted data to debug file: {str(e)}")
@@ -1172,7 +1172,7 @@ class ExtractionService:
extraction_result_format["parts"].append(formatted_part)
result_json = json.dumps(extraction_result_format, indent=2, ensure_ascii=False)
- self._get_service("utils").writeDebugFile(result_json, "content_extraction_original_parts")
+ self._getService("utils").writeDebugFile(result_json, "content_extraction_original_parts")
logger.info(f"Wrote original parts extracted data to debug file: {len(original_parts_extracted_data)} original parts")
except Exception as e:
logger.warning(f"Failed to write original parts extracted data to debug file: {str(e)}")
@@ -1764,11 +1764,11 @@ class ExtractionService:
debugPrefix = f"generation_contentPart_{partId}_{partLabelSafe}"
# Write prompt
- self._get_service("utils").writeDebugFile(prompt, f"{debugPrefix}_prompt")
+ self._getService("utils").writeDebugFile(prompt, f"{debugPrefix}_prompt")
# Write response
responseContent = partResult.content if partResult.content else ""
- self._get_service("utils").writeDebugFile(responseContent, f"{debugPrefix}_response")
+ self._getService("utils").writeDebugFile(responseContent, f"{debugPrefix}_response")
logger.debug(f"Wrote debug files for contentPart {partId} (generation): {debugPrefix}_prompt, {debugPrefix}_response")
except Exception as debugError:
diff --git a/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py b/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py
index fe342002..0f9cbf45 100644
--- a/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py
+++ b/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py
@@ -10,13 +10,7 @@ import logging
from typing import Dict, Any, Optional
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum
-# Type hint for renderer parameter
-from typing import TYPE_CHECKING
-if TYPE_CHECKING:
- from modules.serviceCenter.services.serviceGeneration.renderers.documentRendererBaseTemplate import BaseRenderer
- _RendererLike = BaseRenderer
-else:
- _RendererLike = Any
+from modules.serviceCenter.core.types import RendererProtocol
logger = logging.getLogger(__name__)
@@ -27,7 +21,7 @@ async def buildExtractionPrompt(
title: str,
aiService=None,
services=None,
- renderer: _RendererLike = None
+ renderer: Optional[RendererProtocol] = None
) -> str:
"""
Build unified extraction prompt for extracting content from documents.
diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
index 5dbf16de..1137b7d6 100644
--- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
+++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
@@ -26,10 +26,10 @@ class _ServicesAdapter:
Workflow is read from context dynamically so propagation updates are visible."""
def __init__(self, context, get_service: Callable[[str], Any]):
self._context = context
- self._get_service = get_service
+ self._getService = get_service
self.user = context.user
- self.mandateId = context.mandate_id
- self.featureInstanceId = context.feature_instance_id
+ self.mandateId = context.mandateId
+ self.featureInstanceId = context.featureInstanceId
chat = get_service("chat")
self.interfaceDbChat = chat.interfaceDbChat
@@ -39,22 +39,22 @@ class _ServicesAdapter:
@property
def chat(self):
- return self._get_service("chat")
+ return self._getService("chat")
@property
def utils(self):
- return self._get_service("utils")
+ return self._getService("utils")
@property
def ai(self):
- return self._get_service("ai")
+ return self._getService("ai")
class GenerationService:
def __init__(self, context, get_service: Callable[[str], Any]):
"""Initialize with ServiceCenterContext and service resolver."""
self.services = _ServicesAdapter(context, get_service)
- self._get_service = get_service
+ self._getService = get_service
self.interfaceDbChat = self.services.interfaceDbChat
def processActionResultDocuments(self, actionResult, action) -> List[Dict[str, Any]]:
diff --git a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py
index 87021f9d..cf32a925 100644
--- a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py
+++ b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py
@@ -112,7 +112,7 @@ def _findDsRecord(
sourceType: str,
path: str,
) -> Optional[Dict[str, Any]]:
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath
+ from modules.serviceCenter.core.flagResolution import normalisePath
norm = normalisePath(path)
for ds in allDs:
if (
@@ -191,8 +191,8 @@ def _personalRootChildrenNodes(
mandateId = getattr(context, "mandateId", "") or ""
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=mandateId,
- feature_instance_id="",
+ mandateId=mandateId,
+ featureInstanceId="",
)
chatService = getService("chat", ctx)
connections = chatService.getUserConnections() or []
@@ -295,8 +295,8 @@ async def _connectionServiceNodes(
mandateId = getattr(context, "mandateId", "") or ""
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=mandateId,
- feature_instance_id=instanceId,
+ mandateId=mandateId,
+ featureInstanceId=instanceId,
)
chatService = getService("chat", ctx)
securityService = getService("security", ctx)
@@ -347,8 +347,8 @@ async def _browseChildNodes(
mandateId = getattr(context, "mandateId", "") or ""
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=mandateId,
- feature_instance_id=instanceId,
+ mandateId=mandateId,
+ featureInstanceId=instanceId,
)
chatService = getService("chat", ctx)
securityService = getService("security", ctx)
@@ -683,9 +683,9 @@ def _callerInstanceId(context: Any) -> str:
"""The UDB is feature-agnostic, but `_browseChildNodes` and
`_connectionServiceNodes` need a feature instance id for the
ServiceCenterContext (the underlying connector resolver wants one).
- Use the caller's current feature_instance_id (workspace) when
+ Use the caller's current featureInstanceId (workspace) when
available, else an empty string. The id is NOT used for FDS scoping."""
- fid = getattr(context, "feature_instance_id", None) or getattr(context, "featureInstanceId", None)
+ fid = getattr(context, "featureInstanceId", None)
return str(fid) if fid else ""
diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
index 291dd9a6..095e97cc 100644
--- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
+++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
@@ -926,7 +926,7 @@ class KnowledgeService:
contentObjectId=f"page-{pageIdx}",
fileId=fileId,
userId=self._context.user.id if self._context.user else "",
- featureInstanceId=self._context.feature_instance_id or "",
+ featureInstanceId=self._context.featureInstanceId or "",
contentType="text",
data=text,
contextRef={
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py
index df80898b..5fec915e 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py
@@ -172,7 +172,7 @@ def _loadRagEnabledDataSources(connectionId: str, dataSourceIds: Optional[list]
"""
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelDataSource import DataSource
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
+ from modules.serviceCenter.core.flagResolution import getEffectiveFlag
rootIf = getRootInterface()
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py
index ac886099..edddb2c1 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py
@@ -314,7 +314,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
- mandate_id=str(getattr(connection, "mandateId", "") or ""),
+ mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py
index 9857bfb7..7c485a82 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py
@@ -244,7 +244,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
- mandate_id=str(getattr(connection, "mandateId", "") or ""),
+ mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py
index 150fe839..b07f83c3 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py
@@ -297,7 +297,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
- mandate_id=str(getattr(connection, "mandateId", "") or ""),
+ mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py
index 1c50070e..5dd3174c 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py
@@ -211,7 +211,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
- mandate_id=str(getattr(connection, "mandateId", "") or ""),
+ mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py
index c27a5039..eb131350 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py
@@ -256,7 +256,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
- mandate_id=str(getattr(connection, "mandateId", "") or ""),
+ mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py
index 86d61f60..adb4b841 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py
@@ -245,7 +245,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
- mandate_id=str(getattr(connection, "mandateId", "") or ""),
+ mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService
diff --git a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py
index 4d58933c..f1cd3887 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py
@@ -30,7 +30,7 @@ def _loadRagEnabledFds(featureInstanceId: str, featureDataSourceIds: Optional[Li
"""
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatures import FeatureDataSource
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
+ from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds
rootIf = getRootInterface()
allFds = rootIf.db.getRecordset(
@@ -118,7 +118,7 @@ async def _featureBootstrapHandler(
)
return {"featureInstanceId": featureInstanceId, "skipped": True, "reason": "no_rag_enabled_fds"}
- from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider
+ from modules.serviceCenter.core.types import createFeatureDataProvider
from modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge import IngestionJob
from modules.serviceCenter.context import ServiceCenterContext
from modules.serviceCenter import getService
@@ -156,8 +156,8 @@ async def _featureBootstrapHandler(
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
- mandate_id=mandateId,
- feature_instance_id=fdsFeatureInstanceId,
+ mandateId=mandateId,
+ featureInstanceId=fdsFeatureInstanceId,
)
knowledgeService = getService("knowledge", ctx)
@@ -171,7 +171,7 @@ async def _featureBootstrapHandler(
"explicitFields": set(neutralizeFields),
}
}
- provider = FeatureDataProvider(
+ provider = createFeatureDataProvider(
dbConnector,
neutralizePolicy=neutralizePolicy,
neutralizationService=neutralizationService,
diff --git a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
index d46292ce..879983dd 100644
--- a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
+++ b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
@@ -251,7 +251,7 @@ class _DataSourceFamilyNode(UdbNode):
def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
if not self.supportsFlag(flag):
return False
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
+ from modules.serviceCenter.core.flagResolution import (
resolveEffectiveForPath,
)
out = resolveEffectiveForPath(self.connectionId, self.sourceType, self.path, allDs, mode=mode)
@@ -260,7 +260,7 @@ class _DataSourceFamilyNode(UdbNode):
def setFlag(self, flag, value, rootIf) -> List[str]:
from modules.datamodels.datamodelDataSource import DataSource
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
+ from modules.serviceCenter.core.flagResolution import (
cascadeResetDescendants,
)
if not self.rec:
@@ -416,7 +416,7 @@ class _FdsFamilyNode(UdbNode):
def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
if not self.supportsFlag(flag):
return None
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
+ from modules.serviceCenter.core.flagResolution import (
resolveEffectiveForFds,
)
out = resolveEffectiveForFds(self.featureInstanceId, self.tableName,
@@ -428,7 +428,7 @@ class _FdsFamilyNode(UdbNode):
if not self.supportsFlag(flag):
raise ValueError(f"FDS does not support flag {flag!r}")
from modules.datamodels.datamodelFeatures import FeatureDataSource
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
+ from modules.serviceCenter.core.flagResolution import (
cascadeResetDescendantsFds,
)
if not self.rec:
@@ -669,7 +669,7 @@ class FdsFieldNode(UdbNode):
# Not explicitly overridden -> inherit from the table's effective
# neutralize. Use walk mode so the inherited value is concrete
# (never 'mixed'); a single field cannot itself be ambiguous.
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
+ from modules.serviceCenter.core.flagResolution import (
resolveEffectiveForFds,
)
out = resolveEffectiveForFds(
@@ -753,7 +753,7 @@ def _findOrCreateDs(rootIf: Any, connectionId: str, sourceType: str,
"""
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelUam import UserConnection
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath
+ from modules.serviceCenter.core.flagResolution import normalisePath
normPath = normalisePath(path)
@@ -1007,7 +1007,7 @@ def buildNodeForKey(key: str, context: Any, rootIf: Any) -> Optional[UdbNode]:
def _findDsByCoord(rootIf: Any, connectionId: str, sourceType: Optional[str],
path: str) -> Optional[Dict[str, Any]]:
from modules.datamodels.datamodelDataSource import DataSource
- from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath
+ from modules.serviceCenter.core.flagResolution import normalisePath
rf = {"connectionId": connectionId}
if sourceType is not None:
rf["sourceType"] = sourceType
diff --git a/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py b/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py
index 77e77695..cc43ca0c 100644
--- a/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py
+++ b/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py
@@ -33,7 +33,7 @@ class _ServicesAdapter:
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
self.interfaceDbComponent = getComponentInterface(
context.user,
- mandateId=context.mandate_id
+ mandateId=context.mandateId
)
diff --git a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py
index 8456dc52..e6cbc8e4 100644
--- a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py
+++ b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py
@@ -24,13 +24,13 @@ class SharepointService:
"""Initialize SharePoint service without access token.
Args:
- context: ServiceCenterContext with user, mandate_id, etc.
+ context: ServiceCenterContext with user, mandateId, etc.
get_service: Service resolver for dependency injection (e.g. security)
Use setAccessTokenFromConnection() method to configure the access token before making API calls.
"""
self._context = context
- self._get_service = get_service
+ self._getService = get_service
self.accessToken = None
self.baseUrl = "https://graph.microsoft.com/v1.0"
@@ -59,7 +59,7 @@ class SharepointService:
return False
# Get a fresh token for this specific connection via security service
- security = self._get_service("security")
+ security = self._getService("security")
if not security:
logger.error("Security service not available for token access")
return False
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index 71dc4526..e5924aaf 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -55,11 +55,11 @@ class SubscriptionService:
if mandateId is not None and callable(mandateId):
ctx = contextOrUser
self.currentUser = ctx.user
- self.mandateId = ctx.mandate_id or ""
+ self.mandateId = ctx.mandateId or ""
elif get_service is not None and hasattr(contextOrUser, "user"):
ctx = contextOrUser
self.currentUser = ctx.user
- self.mandateId = ctx.mandate_id or ""
+ self.mandateId = ctx.mandateId or ""
else:
self.currentUser = contextOrUser
self.mandateId = mandateId or ""
diff --git a/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py b/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py
index 10ad1ba6..ea229940 100644
--- a/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py
+++ b/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py
@@ -15,7 +15,7 @@ class TicketService:
def __init__(self, context, get_service: Callable[[str], Any]):
"""Initialize with context and service resolver."""
self._context = context
- self._get_service = get_service
+ self._getService = get_service
async def connectTicket(
self,
diff --git a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py
index 3445839e..c6403c8d 100644
--- a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py
+++ b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py
@@ -22,14 +22,14 @@ class WebService:
def __init__(self, context, get_service):
"""Initialize webcrawl service with context and service resolver."""
self._context = context
- self._get_service = get_service
+ self._getService = get_service
def _workflow_id(self):
"""Get workflow ID for operation IDs."""
if self._context.workflow:
return self._context.workflow.id
- if self._context.workflow_id:
- return self._context.workflow_id
+ if self._context.workflowId:
+ return self._context.workflowId
return f"no-workflow-{int(time.time())}"
async def performWebResearch(
@@ -61,7 +61,7 @@ class WebService:
"""
# Start progress tracking if operationId provided
if operationId:
- self._get_service("chat").progressLogStart(
+ self._getService("chat").progressLogStart(
operationId,
"Web Research",
"Research",
@@ -71,7 +71,7 @@ class WebService:
try:
# Step 1: AI intention analysis - extract URLs and parameters from prompt
if operationId:
- self._get_service("chat").progressLogUpdate(operationId, 0.1, "Analyzing research intent")
+ self._getService("chat").progressLogUpdate(operationId, 0.1, "Analyzing research intent")
analysisResult = await self._analyzeResearchIntent(prompt, urls, country, language, researchDepth)
@@ -99,7 +99,7 @@ class WebService:
searchResultsWithContent = []
if needsSearch and (not allUrls or len(allUrls) < maxNumberPages):
if operationId:
- self._get_service("chat").progressLogUpdate(operationId, 0.3, "Searching for URLs and content")
+ self._getService("chat").progressLogUpdate(operationId, 0.3, "Searching for URLs and content")
try:
searchUrls, searchResultsWithContent = await self._performWebSearch(
@@ -121,7 +121,7 @@ class WebService:
logger.warning("Tavily search returned no URLs, using AI-extracted URLs only")
if operationId:
- self._get_service("chat").progressLogUpdate(operationId, 0.5, f"Found {len(allUrls)} total URLs")
+ self._getService("chat").progressLogUpdate(operationId, 0.5, f"Found {len(allUrls)} total URLs")
# If we have search results (even without content), use them directly instead of crawling
# Tavily search results are more relevant than generic AI-extracted URLs
@@ -179,7 +179,7 @@ class WebService:
"total_urls": len(searchUrls),
"urls_with_content": urlsWithContent,
"total_content_length": totalContentLength,
- "search_date": self._get_service("utils").timestampGetUtc()
+ "search_date": self._getService("utils").timestampGetUtc()
},
"sections": sections,
"statistics": {
@@ -201,8 +201,8 @@ class WebService:
result["metadata"]["suggested_filename"] = suggestedFilename
if operationId:
- self._get_service("chat").progressLogUpdate(operationId, 0.9, "Completed")
- self._get_service("chat").progressLogFinish(operationId, True)
+ self._getService("chat").progressLogUpdate(operationId, 0.9, "Completed")
+ self._getService("chat").progressLogFinish(operationId, True)
return result
@@ -231,8 +231,8 @@ class WebService:
# Step 5: Crawl all URLs with hierarchical logging
if operationId:
- self._get_service("chat").progressLogUpdate(operationId, 0.4, "Initiating")
- self._get_service("chat").progressLogUpdate(operationId, 0.6, f"Crawling {len(validatedUrls)} URLs")
+ self._getService("chat").progressLogUpdate(operationId, 0.4, "Initiating")
+ self._getService("chat").progressLogUpdate(operationId, 0.6, f"Crawling {len(validatedUrls)} URLs")
# Use parent operation ID directly (parentId should be operationId, not log entry ID)
parentOperationId = operationId # Use the parent's operationId directly
@@ -246,9 +246,9 @@ class WebService:
)
if operationId:
- self._get_service("chat").progressLogUpdate(operationId, 0.9, "Consolidating results")
- self._get_service("chat").progressLogUpdate(operationId, 0.95, "Completed")
- self._get_service("chat").progressLogFinish(operationId, True)
+ self._getService("chat").progressLogUpdate(operationId, 0.9, "Consolidating results")
+ self._getService("chat").progressLogUpdate(operationId, 0.95, "Completed")
+ self._getService("chat").progressLogFinish(operationId, True)
# Calculate statistics about crawl results
totalResults = len(crawlResult) if isinstance(crawlResult, list) else 1
@@ -317,7 +317,7 @@ class WebService:
"total_urls": len(validatedUrls),
"urls_with_content": urlsWithContent,
"total_content_length": totalContentLength,
- "crawl_date": self._get_service("utils").timestampGetUtc()
+ "crawl_date": self._getService("utils").timestampGetUtc()
},
"sections": sections,
"statistics": {
@@ -345,7 +345,7 @@ class WebService:
except Exception as e:
logger.error(f"Error in web research: {str(e)}")
if operationId:
- self._get_service("chat").progressLogFinish(operationId, False)
+ self._getService("chat").progressLogFinish(operationId, False)
raise
async def _analyzeResearchIntent(
@@ -397,13 +397,13 @@ Return ONLY valid JSON, no additional text:
try:
# Call AI planning to analyze intent
- analysisJson = await self._get_service("ai").callAiPlanning(
+ analysisJson = await self._getService("ai").callAiPlanning(
analysisPrompt,
debugType="webresearchintent"
)
# Extract JSON from response (handles markdown code blocks)
- extractedJson = self._get_service("utils").jsonExtractString(analysisJson)
+ extractedJson = self._getService("utils").jsonExtractString(analysisJson)
if not extractedJson:
raise ValueError("No JSON found in AI response")
@@ -454,7 +454,7 @@ Return ONLY valid JSON, no additional text:
searchPrompt = searchPromptModel.model_dump_json(exclude_none=True, indent=2)
# Debug: persist search prompt
- self._get_service("utils").writeDebugFile(searchPrompt, "websearch_prompt")
+ self._getService("utils").writeDebugFile(searchPrompt, "websearch_prompt")
# Call AI with WEB_SEARCH_DATA operation
searchOptions = AiCallOptions(
@@ -463,7 +463,7 @@ Return ONLY valid JSON, no additional text:
)
# Use unified callAiContent method
- searchResponse = await self._get_service("ai").callAiContent(
+ searchResponse = await self._getService("ai").callAiContent(
prompt=searchPrompt,
options=searchOptions,
outputFormat="json"
@@ -518,16 +518,16 @@ Return ONLY valid JSON, no additional text:
# Debug: persist search response
if isinstance(searchResult, str):
- self._get_service("utils").writeDebugFile(searchResult, "websearch_response")
+ self._getService("utils").writeDebugFile(searchResult, "websearch_response")
logger.debug(f"Search response (first 500 chars): {searchResult[:500]}")
else:
- self._get_service("utils").writeDebugFile(json.dumps(searchResult, indent=2), "websearch_response")
+ self._getService("utils").writeDebugFile(json.dumps(searchResult, indent=2), "websearch_response")
logger.debug(f"Search response type: {type(searchResult)}, keys: {list(searchResult.keys()) if isinstance(searchResult, dict) else 'N/A'}")
# Parse and extract URLs and content
if isinstance(searchResult, str):
# Extract JSON from response (handles markdown code blocks)
- extractedJson = self._get_service("utils").jsonExtractString(searchResult)
+ extractedJson = self._getService("utils").jsonExtractString(searchResult)
if extractedJson:
try:
searchData = json.loads(extractedJson)
@@ -800,7 +800,7 @@ Return ONLY valid JSON, no additional text:
if parentOperationId:
workflowId = self._workflow_id()
urlOperationId = f"web_crawl_url_{workflowId}_{urlIndex}_{int(time.time())}"
- self._get_service("chat").progressLogStart(
+ self._getService("chat").progressLogStart(
urlOperationId,
"Web Crawl",
f"URL {urlIndex + 1}/{totalUrls}",
@@ -813,8 +813,8 @@ Return ONLY valid JSON, no additional text:
if urlOperationId:
displayUrl = url[:50] + "..." if len(url) > 50 else url
- self._get_service("chat").progressLogUpdate(urlOperationId, 0.2, f"Crawling: {displayUrl}")
- self._get_service("chat").progressLogUpdate(urlOperationId, 0.3, "Initiating crawl")
+ self._getService("chat").progressLogUpdate(urlOperationId, 0.2, f"Crawling: {displayUrl}")
+ self._getService("chat").progressLogUpdate(urlOperationId, 0.3, "Initiating crawl")
# Build crawl prompt model for single URL
# maxWidth is passed from performWebResearch based on researchDepth
@@ -829,7 +829,7 @@ Return ONLY valid JSON, no additional text:
# Debug: persist crawl prompt (with URL identifier in content for clarity)
debugPrompt = f"URL: {url}\n\n{crawlPrompt}"
- self._get_service("utils").writeDebugFile(debugPrompt, "webcrawl_prompt")
+ self._getService("utils").writeDebugFile(debugPrompt, "webcrawl_prompt")
# Call AI with WEB_CRAWL operation
crawlOptions = AiCallOptions(
@@ -838,10 +838,10 @@ Return ONLY valid JSON, no additional text:
)
if urlOperationId:
- self._get_service("chat").progressLogUpdate(urlOperationId, 0.4, "Calling crawl connector")
+ self._getService("chat").progressLogUpdate(urlOperationId, 0.4, "Calling crawl connector")
# Use unified callAiContent method with parentOperationId for hierarchical logging
- crawlResponse = await self._get_service("ai").callAiContent(
+ crawlResponse = await self._getService("ai").callAiContent(
prompt=crawlPrompt,
options=crawlOptions,
outputFormat="json",
@@ -849,22 +849,22 @@ Return ONLY valid JSON, no additional text:
)
if urlOperationId:
- self._get_service("chat").progressLogUpdate(urlOperationId, 0.7, "Processing crawl results")
+ self._getService("chat").progressLogUpdate(urlOperationId, 0.7, "Processing crawl results")
# Extract content from AiResponse
crawlResult = crawlResponse.content
# Debug: persist crawl response
if isinstance(crawlResult, str):
- self._get_service("utils").writeDebugFile(crawlResult, "webcrawl_response")
+ self._getService("utils").writeDebugFile(crawlResult, "webcrawl_response")
else:
- self._get_service("utils").writeDebugFile(json.dumps(crawlResult, indent=2), "webcrawl_response")
+ self._getService("utils").writeDebugFile(json.dumps(crawlResult, indent=2), "webcrawl_response")
# Parse crawl result
if isinstance(crawlResult, str):
try:
# Extract JSON from response (handles markdown code blocks)
- extractedJson = self._get_service("utils").jsonExtractString(crawlResult)
+ extractedJson = self._getService("utils").jsonExtractString(crawlResult)
crawlData = json.loads(extractedJson) if extractedJson else json.loads(crawlResult)
except:
crawlData = {"url": url, "content": crawlResult}
@@ -873,7 +873,7 @@ Return ONLY valid JSON, no additional text:
# Process crawl results and create hierarchical progress logging for sub-URLs
if urlOperationId:
- self._get_service("chat").progressLogUpdate(urlOperationId, 0.8, "Processing crawl results")
+ self._getService("chat").progressLogUpdate(urlOperationId, 0.8, "Processing crawl results")
# Recursively process crawl results to find nested URLs and create child operations
processedResults = self._processCrawlResultsWithHierarchy(crawlData, url, urlOperationId, maxDepth, 0)
@@ -891,17 +891,17 @@ Return ONLY valid JSON, no additional text:
if urlOperationId:
if totalUrlsCrawled > 1:
- self._get_service("chat").progressLogUpdate(urlOperationId, 0.9, f"Crawled {totalUrlsCrawled} URLs (including sub-URLs)")
+ self._getService("chat").progressLogUpdate(urlOperationId, 0.9, f"Crawled {totalUrlsCrawled} URLs (including sub-URLs)")
else:
- self._get_service("chat").progressLogUpdate(urlOperationId, 0.9, "Crawl completed")
- self._get_service("chat").progressLogFinish(urlOperationId, True)
+ self._getService("chat").progressLogUpdate(urlOperationId, 0.9, "Crawl completed")
+ self._getService("chat").progressLogFinish(urlOperationId, True)
return results
except Exception as e:
logger.error(f"Error crawling URL {url}: {str(e)}")
if urlOperationId:
- self._get_service("chat").progressLogFinish(urlOperationId, False)
+ self._getService("chat").progressLogFinish(urlOperationId, False)
return [{"url": url, "error": str(e)}]
def _processCrawlResultsWithHierarchy(
@@ -943,7 +943,7 @@ Return ONLY valid JSON, no additional text:
# This is a sub-URL - create child operation
workflowId = self._workflow_id()
subUrlOperationId = f"{parentOperationId}_sub_{idx}_{int(time.time())}"
- self._get_service("chat").progressLogStart(
+ self._getService("chat").progressLogStart(
subUrlOperationId,
"Crawling Sub-URL",
f"Depth {currentDepth + 1}",
@@ -969,12 +969,12 @@ Return ONLY valid JSON, no additional text:
)
item["subUrls"] = nestedResults
- self._get_service("chat").progressLogUpdate(subUrlOperationId, 0.9, "Completed")
- self._get_service("chat").progressLogFinish(subUrlOperationId, True)
+ self._getService("chat").progressLogUpdate(subUrlOperationId, 0.9, "Completed")
+ self._getService("chat").progressLogFinish(subUrlOperationId, True)
except Exception as e:
logger.error(f"Error processing sub-URL {itemUrl}: {str(e)}")
if subUrlOperationId:
- self._get_service("chat").progressLogFinish(subUrlOperationId, False)
+ self._getService("chat").progressLogFinish(subUrlOperationId, False)
results.append(item)
else:
diff --git a/modules/shared/systemComponentRegistry.py b/modules/shared/systemComponentRegistry.py
index e4733a68..70cd485d 100644
--- a/modules/shared/systemComponentRegistry.py
+++ b/modules/shared/systemComponentRegistry.py
@@ -6,7 +6,9 @@ Higher-layer system components (e.g. workflowAutomation) register their
lifecycle hooks here at boot time via ``app.py`` (Composition Root, L7).
Interface modules read the registry generically — no upward imports needed.
-Supported events: ``onBootstrap``, ``onMandateDelete``, ``onInstanceCreate``.
+Supported events: ``onBootstrap``, ``onMandateDelete``, ``onMandateProvision``,
+``onInstanceCreate``, ``onUserMandateCreate``, ``onUserMandateDelete``,
+``onUserBudgetAdjust``, ``onStorageChanged``.
This is the same inversion pattern used by
``serviceAgent/externalToolRegistry.py`` for agent tools.
diff --git a/modules/workflowAutomation/editor/_valueKindResolver.py b/modules/workflowAutomation/editor/_valueKindResolver.py
new file mode 100644
index 00000000..63dd849d
--- /dev/null
+++ b/modules/workflowAutomation/editor/_valueKindResolver.py
@@ -0,0 +1,102 @@
+# Copyright (c) 2025 Patrick Motsch
+"""Shared value-kind resolution helpers.
+
+Extracted from conditionOperators so that upstreamPathsService can resolve
+value kinds without importing conditionOperators (breaking the bidirectional
+import cycle).
+"""
+from __future__ import annotations
+
+from typing import Any, Dict, List
+
+
+def catalogTypeToValueKind(catalogType: str) -> str:
+ """Map port-catalog / dataPickOptions type strings to condition valueKind."""
+ ct = (catalogType or "").strip()
+ if not ct or ct == "Any":
+ return "unknown"
+ low = ct.lower()
+ if low in ("str", "string", "email", "url"):
+ return "string"
+ if low in ("int", "float", "number"):
+ return "number"
+ if low == "bool":
+ return "boolean"
+ if low in ("date", "datetime", "timestamp"):
+ return "datetime"
+ if low.startswith("list[") or low == "list":
+ return "array"
+ if low.startswith("dict") or low == "dict":
+ return "object"
+ if low in ("file", "actiondocument", "fileref"):
+ return "file"
+ return "unknown"
+
+
+def _isContextProducer(nodeType: str) -> bool:
+ return nodeType in ("context.extractContent", "context.mergeContext", "context.setContext")
+
+
+def _pathSuggestsContext(path: List[Any], producerType: str) -> bool:
+ if not path:
+ return _isContextProducer(producerType)
+ last = str(path[-1])
+ if last in ("data", "files", "merged", "presentation"):
+ return True
+ if "files" in [str(p) for p in path]:
+ return True
+ if _isContextProducer(producerType) and path[0] in ("data", "response", "merged"):
+ return True
+ return False
+
+
+def _pathSuggestsFile(path: List[Any], producerType: str) -> bool:
+ pathStr = [str(p) for p in path]
+ if producerType == "input.upload":
+ return True
+ if "file" in pathStr or "documents" in pathStr or "mimeType" in pathStr or "fileName" in pathStr:
+ return True
+ if producerType.startswith("sharepoint.") and "file" in pathStr:
+ return True
+ return False
+
+
+def _pathsEqual(a: List[Any], b: List[Any]) -> bool:
+ if len(a) != len(b):
+ return False
+ return all(str(x) == str(y) for x, y in zip(a, b))
+
+
+def resolveValueKindFromRef(graph: Dict[str, Any], ref: Dict[str, Any]) -> str:
+ """Resolve condition valueKind using graph-local heuristics only.
+
+ Unlike ``conditionOperators.resolve_value_kind`` this does NOT call
+ ``compute_upstream_paths``, so it is safe to import from
+ upstreamPathsService without creating a cycle.
+ """
+ if not isinstance(ref, dict):
+ return "unknown"
+ producerId = ref.get("nodeId")
+ path = ref.get("path") or []
+ if not isinstance(path, list):
+ path = []
+ if not producerId:
+ return "unknown"
+
+ nodes = graph.get("nodes") or []
+ nodeById = {n.get("id"): n for n in nodes if n.get("id")}
+ producer = nodeById.get(producerId) or {}
+ producerType = str(producer.get("type") or "")
+
+ if _pathSuggestsContext(path, producerType):
+ return "context"
+ if _pathSuggestsFile(path, producerType):
+ tail = str(path[-1]) if path else ""
+ if tail in ("mimeType", "fileName"):
+ return "string"
+ return "file"
+
+ if producerType in ("trigger.form", "input.form") and path and str(path[0]) == "payload":
+ return "string"
+
+ return "unknown"
diff --git a/modules/workflowAutomation/editor/conditionOperators.py b/modules/workflowAutomation/editor/conditionOperators.py
index e99defc1..5b5d611a 100644
--- a/modules/workflowAutomation/editor/conditionOperators.py
+++ b/modules/workflowAutomation/editor/conditionOperators.py
@@ -10,6 +10,12 @@ from typing import Any, Dict, List, Optional, Tuple
from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
from modules.shared.i18nRegistry import resolveText, t
+from modules.workflowAutomation.editor._valueKindResolver import (
+ catalogTypeToValueKind as catalog_type_to_value_kind,
+ _pathSuggestsContext as _path_suggests_context,
+ _pathSuggestsFile as _path_suggests_file,
+ _pathsEqual as _paths_equal,
+)
logger = logging.getLogger(__name__)
@@ -200,64 +206,7 @@ def localize_operator_catalog(lang: str = "de") -> Dict[str, List[Dict[str, Any]
return out
-def catalog_type_to_value_kind(catalog_type: str) -> str:
- """Map port-catalog / dataPickOptions type strings to condition valueKind."""
- ct = (catalog_type or "").strip()
- if not ct or ct == "Any":
- return "unknown"
- low = ct.lower()
- if low in ("str", "string", "email", "url"):
- return "string"
- if low in ("int", "float", "number"):
- return "number"
- if low == "bool":
- return "boolean"
- if low in ("date", "datetime", "timestamp"):
- return "datetime"
- if low.startswith("list[") or low == "list":
- return "array"
- if low.startswith("dict") or low == "dict":
- return "object"
- if low in ("file", "actiondocument", "fileref"):
- return "file"
- return "unknown"
-
-
-def _paths_equal(a: List[Any], b: List[Any]) -> bool:
- if len(a) != len(b):
- return False
- return all(str(x) == str(y) for x, y in zip(a, b))
-
-
-def _is_context_producer(node_type: str) -> bool:
- return node_type in ("context.extractContent", "context.mergeContext", "context.setContext")
-
-
-def _path_suggests_context(path: List[Any], producer_type: str) -> bool:
- if not path:
- return _is_context_producer(producer_type)
- last = str(path[-1])
- if last in ("data", "files", "merged", "presentation"):
- return True
- if "files" in [str(p) for p in path]:
- return True
- if _is_context_producer(producer_type) and path[0] in ("data", "response", "merged"):
- return True
- return False
-
-
-def _path_suggests_file(path: List[Any], producer_type: str) -> bool:
- path_str = [str(p) for p in path]
- if producer_type == "input.upload":
- return True
- if "file" in path_str or "documents" in path_str or "mimeType" in path_str or "fileName" in path_str:
- return True
- if producer_type.startswith("sharepoint.") and "file" in path_str:
- return True
- return False
-
-
-def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upstream: bool = False) -> str:
+def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any]) -> str:
"""Resolve condition valueKind for a DataRef against the workflow graph."""
if not isinstance(ref, dict):
return "unknown"
@@ -281,32 +230,31 @@ def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upst
return "string"
return "file"
- if not _skip_upstream:
- from modules.workflowAutomation.editor.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
+ target_id = graph.get("targetNodeId") or producer_id
+ matched_type: Optional[str] = None
+ for entry in compute_upstream_paths(graph, target_id):
+ if entry.get("producerNodeId") != producer_id:
+ continue
+ entry_path = entry.get("path") or []
+ if _paths_equal(list(entry_path), list(path)):
+ matched_type = str(entry.get("type") or "Any")
+ break
+
+ if matched_type is None and path:
+ parent_path = list(path[:-1])
for entry in compute_upstream_paths(graph, target_id):
if entry.get("producerNodeId") != producer_id:
continue
- entry_path = entry.get("path") or []
- if _paths_equal(list(entry_path), list(path)):
+ if _paths_equal(list(entry.get("path") or []), parent_path):
matched_type = str(entry.get("type") or "Any")
break
- if matched_type is None and path:
- parent_path = list(path[:-1])
- for entry in compute_upstream_paths(graph, target_id):
- if entry.get("producerNodeId") != producer_id:
- continue
- if _paths_equal(list(entry.get("path") or []), parent_path):
- matched_type = str(entry.get("type") or "Any")
- break
-
- if matched_type:
- vk = catalog_type_to_value_kind(matched_type)
- if vk != "unknown":
- return vk
+ if matched_type:
+ vk = catalog_type_to_value_kind(matched_type)
+ if vk != "unknown":
+ return vk
if producer_type in ("trigger.form", "input.form") and path and str(path[0]) == "payload":
return "string"
diff --git a/modules/workflowAutomation/editor/upstreamPathsService.py b/modules/workflowAutomation/editor/upstreamPathsService.py
index 3639b7b7..a98be149 100644
--- a/modules/workflowAutomation/editor/upstreamPathsService.py
+++ b/modules/workflowAutomation/editor/upstreamPathsService.py
@@ -4,7 +4,10 @@ from __future__ import annotations
from typing import Any, Dict, List, Set
-from modules.workflowAutomation.editor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind
+from modules.workflowAutomation.editor._valueKindResolver import (
+ catalogTypeToValueKind,
+ resolveValueKindFromRef,
+)
from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema
from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds
@@ -170,14 +173,14 @@ def compute_upstream_paths(graph: Dict[str, Any], target_node_id: str) -> List[D
for entry in paths:
ct = str(entry.get("type") or "Any")
- vk = catalog_type_to_value_kind(ct)
+ vk = catalogTypeToValueKind(ct)
if vk == "unknown":
ref = {
"nodeId": entry.get("producerNodeId"),
"path": entry.get("path") or [],
}
graph_with_target = {**graph, "targetNodeId": target_node_id}
- vk = resolve_value_kind(graph_with_target, ref, _skip_upstream=True)
+ vk = resolveValueKindFromRef(graph_with_target, ref)
entry["valueKind"] = vk
return paths
diff --git a/modules/workflowAutomation/engine/_runNotifications.py b/modules/workflowAutomation/engine/_runNotifications.py
new file mode 100644
index 00000000..c8d7786d
--- /dev/null
+++ b/modules/workflowAutomation/engine/_runNotifications.py
@@ -0,0 +1,118 @@
+# Copyright (c) 2025 Patrick Motsch
+"""Run failure notification helpers.
+
+Extracted from scheduler/mainScheduler to break the bidirectional import
+cycle between executionEngine and mainScheduler. The engine calls
+``notifyRunFailed`` directly (same subfolder, no cycle).
+"""
+
+import logging
+from typing import Optional
+
+from modules.shared.eventManagement import eventManager
+
+logger = logging.getLogger(__name__)
+
+
+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("workflowAutomation.run.failed", {
+ "workflowId": workflowId,
+ "runId": runId,
+ "error": error,
+ "mandateId": mandateId,
+ })
+ logger.info("Emitted run.failed event for run %s (workflow %s)", runId, workflowId)
+ except Exception as e:
+ logger.warning("Failed to emit run.failed event: %s", e)
+
+ _createRunFailedNotification(workflowId, runId, error, mandateId, workflowLabel)
+ _triggerRunFailedSubscription(workflowId, runId, error, mandateId, workflowLabel)
+
+
+def _createRunFailedNotification(
+ workflowId: str,
+ runId: str,
+ error: str,
+ mandateId: str = None,
+ workflowLabel: str = None,
+) -> None:
+ """Create in-app notification for the workflow creator."""
+ try:
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ from modules.datamodels.datamodelNotification import UserNotification, NotificationType, NotificationStatus
+
+ rootInterface = getRootInterface()
+ if not rootInterface:
+ return
+
+ from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
+ eventUser = rootInterface.getUserByUsername("event")
+ if not eventUser:
+ return
+
+ iface = _getWorkflowAutomationInterface(eventUser, mandateId or "", "")
+ wf = iface.getWorkflow(workflowId)
+ if not wf:
+ return
+
+ creatorId = wf.get("sysCreatedBy") if isinstance(wf, dict) else getattr(wf, "sysCreatedBy", None)
+ if not creatorId:
+ return
+
+ label = workflowLabel or (wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", ""))
+ notification = UserNotification(
+ userId=creatorId,
+ type=NotificationType.SYSTEM,
+ status=NotificationStatus.UNREAD,
+ title="Workflow fehlgeschlagen",
+ message=f"Workflow '{label or workflowId}' ist fehlgeschlagen: {error[:200]}",
+ referenceType="AutoRun",
+ referenceId=runId,
+ icon="alert-triangle",
+ )
+ rootInterface.db.recordCreate(
+ model_class=UserNotification,
+ record=notification.model_dump(),
+ )
+ logger.info("Created in-app notification for user %s (run %s)", creatorId, runId)
+ except Exception as e:
+ logger.warning("Failed to create in-app run.failed notification: %s", e)
+
+
+_onRunFailedCallback = None
+
+
+def setOnRunFailedCallback(callback) -> None:
+ """Set the callback for run failure notifications (injected by app.py)."""
+ global _onRunFailedCallback
+ _onRunFailedCallback = callback
+
+
+def _triggerRunFailedSubscription(
+ workflowId: str,
+ runId: str,
+ error: str,
+ mandateId: str = None,
+ workflowLabel: str = None,
+) -> None:
+ """Trigger the messaging subscription for run failures via injected callback."""
+ if _onRunFailedCallback is None:
+ return
+ try:
+ _onRunFailedCallback(
+ workflowId=workflowId,
+ runId=runId,
+ error=error,
+ mandateId=mandateId,
+ workflowLabel=workflowLabel,
+ )
+ except Exception as e:
+ logger.warning("Failed to trigger run.failed subscription: %s", e)
diff --git a/modules/workflowAutomation/engine/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py
index 99f7c2ed..b1c877c2 100644
--- a/modules/workflowAutomation/engine/executionEngine.py
+++ b/modules/workflowAutomation/engine/executionEngine.py
@@ -1540,15 +1540,6 @@ async def executeGraph(
duration_ms=_emailPauseMs,
)
logger.info("executeGraph paused for email wait (run %s, node %s)", e.runId, e.nodeId)
- try:
- from modules.interfaces.interfaceDbApp import getRootInterface
- from modules.workflowAutomation.scheduler.emailPoller import ensureRunning
- root = getRootInterface()
- event_user = root.getUserByUsername("event") if root else None
- if event_user:
- ensureRunning(event_user)
- except Exception as poll_err:
- logger.warning("Could not start email poller: %s", poll_err)
paused_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
run_ctx = {
"connectionMap": context.get("connectionMap"),
@@ -1612,7 +1603,7 @@ async def executeGraph(
) if _wfObj else {}
_shouldNotify = _wfDict.get("notifyOnFailure", True) if _wfDict else True
if _shouldNotify:
- from modules.workflowAutomation.scheduler.mainScheduler import notifyRunFailed
+ from modules.workflowAutomation.engine._runNotifications import notifyRunFailed
notifyRunFailed(
workflowId or "", runId or "", str(e),
mandateId=mandateId,
diff --git a/modules/workflowAutomation/engine/graphUtils.py b/modules/workflowAutomation/engine/graphUtils.py
index c6a4b5cd..68368b48 100644
--- a/modules/workflowAutomation/engine/graphUtils.py
+++ b/modules/workflowAutomation/engine/graphUtils.py
@@ -383,6 +383,21 @@ def _pathContainsWildcard(path: List[Any]) -> bool:
# (``featureInstanceRefMigration.materializeFeatureInstanceRefs``) writes the
# envelope, the resolver unwraps it on its way to the action.
+_STALE_FILE_CREATE_CONTEXT_PATHS = frozenset({
+ ("responseData",),
+ ("response",),
+ ("merged",),
+ ("documents", 0, "documentData"),
+})
+
+
+def remap_stale_presentation_ref_path(path: List[Any]) -> List[Any]:
+ """Map legacy text-handover paths to unified presentation ``data``."""
+ if tuple(path) in _STALE_FILE_CREATE_CONTEXT_PATHS:
+ return ["data"]
+ return list(path)
+
+
_TYPED_REF_PRIMARY_FIELD = {
"FeatureInstanceRef": "id",
"ConnectionRef": "id",
@@ -450,9 +465,6 @@ def resolveParameterReferences(
plist = list(path)
resolved = _get_by_path(data, plist)
if resolved is None:
- from modules.workflowAutomation.engine.pickNotPushMigration import (
- remap_stale_presentation_ref_path,
- )
alt_path = remap_stale_presentation_ref_path(plist)
if alt_path != plist:
resolved = _get_by_path(data, alt_path)
diff --git a/modules/workflowAutomation/engine/pickNotPushMigration.py b/modules/workflowAutomation/engine/pickNotPushMigration.py
index 78bd63c4..14b91eae 100644
--- a/modules/workflowAutomation/engine/pickNotPushMigration.py
+++ b/modules/workflowAutomation/engine/pickNotPushMigration.py
@@ -21,7 +21,11 @@ from modules.nodeCatalog.portTypes import (
PRIMARY_TEXT_HANDOVER_REF_PATH,
resolve_output_schema_name,
)
-from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getInputSources
+from modules.workflowAutomation.engine.graphUtils import (
+ buildConnectionMap,
+ getInputSources,
+ remap_stale_presentation_ref_path,
+)
logger = logging.getLogger(__name__)
@@ -243,20 +247,6 @@ def materializeRecommendedDataPickRef(graph: Dict[str, Any]) -> Dict[str, Any]:
return g
-_STALE_FILE_CREATE_CONTEXT_PATHS = frozenset({
- ("responseData",),
- ("response",),
- ("merged",),
- ("documents", 0, "documentData"),
-})
-
-
-def remap_stale_presentation_ref_path(path: List[Any]) -> List[Any]:
- """Map legacy text-handover paths to unified presentation ``data``."""
- if tuple(path) in _STALE_FILE_CREATE_CONTEXT_PATHS:
- return ["data"]
- return list(path)
-
def _normalize_presentation_refs_in_value(val: Any) -> Any:
"""Rewrite stale ref paths inside ``contextBuilder`` lists or bare refs."""
diff --git a/modules/workflowAutomation/mainWorkflowAutomation.py b/modules/workflowAutomation/mainWorkflowAutomation.py
index e3a38d84..a05064c9 100644
--- a/modules/workflowAutomation/mainWorkflowAutomation.py
+++ b/modules/workflowAutomation/mainWorkflowAutomation.py
@@ -57,8 +57,8 @@ def _getWorkflowAutomationServices(
ctx = ServiceCenterContext(
user=user,
- mandate_id=mandateId,
- feature_instance_id=featureInstanceId,
+ mandateId=mandateId,
+ featureInstanceId=featureInstanceId,
workflow=_workflow,
)
return ServicesBag(ctx, lambda key: getService(key, ctx))
diff --git a/modules/workflowAutomation/scheduler/__init__.py b/modules/workflowAutomation/scheduler/__init__.py
index d5178091..ab966ca5 100644
--- a/modules/workflowAutomation/scheduler/__init__.py
+++ b/modules/workflowAutomation/scheduler/__init__.py
@@ -6,6 +6,8 @@ from modules.workflowAutomation.scheduler.mainScheduler import (
stop,
syncNow,
setMainLoop,
+)
+from modules.workflowAutomation.engine._runNotifications import (
notifyRunFailed,
setOnRunFailedCallback,
)
diff --git a/modules/workflowAutomation/scheduler/mainScheduler.py b/modules/workflowAutomation/scheduler/mainScheduler.py
index ec368480..a0ced9cc 100644
--- a/modules/workflowAutomation/scheduler/mainScheduler.py
+++ b/modules/workflowAutomation/scheduler/mainScheduler.py
@@ -263,6 +263,12 @@ class WorkflowScheduler:
"WorkflowScheduler: executed workflow %s success=%s paused=%s",
workflowId, result.get("success"), result.get("paused"),
)
+ if result.get("waitReason") == "email":
+ try:
+ from modules.workflowAutomation.scheduler.emailPoller import ensureRunning
+ ensureRunning(eventUser)
+ except Exception as pollErr:
+ logger.warning("WorkflowScheduler: could not start email poller: %s", pollErr)
except Exception as e:
logger.exception("WorkflowScheduler: failed to execute workflow %s: %s", workflowId, e)
@@ -333,94 +339,10 @@ def _cronToIntervalSeconds(cron: str):
return None
-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("workflowAutomation.run.failed", {
- "workflowId": workflowId,
- "runId": runId,
- "error": error,
- "mandateId": mandateId,
- })
- logger.info("Emitted run.failed event for run %s (workflow %s)", runId, workflowId)
- except Exception as e:
- logger.warning("Failed to emit run.failed event: %s", e)
-
- _createRunFailedNotification(workflowId, runId, error, mandateId, workflowLabel)
- _triggerRunFailedSubscription(workflowId, runId, error, mandateId, workflowLabel)
-
-
-def _createRunFailedNotification(
- workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None
-) -> None:
- """Create in-app notification for the workflow creator."""
- try:
- from modules.interfaces.interfaceDbApp import getRootInterface
- from modules.datamodels.datamodelNotification import UserNotification, NotificationType, NotificationStatus
-
- rootInterface = getRootInterface()
- if not rootInterface:
- return
-
- from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
- eventUser = rootInterface.getUserByUsername("event")
- if not eventUser:
- return
-
- iface = _getWorkflowAutomationInterface(eventUser, mandateId or "", "")
- wf = iface.getWorkflow(workflowId)
- if not wf:
- return
-
- creatorId = wf.get("sysCreatedBy") if isinstance(wf, dict) else getattr(wf, "sysCreatedBy", None)
- if not creatorId:
- return
-
- label = workflowLabel or (wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", ""))
- notification = UserNotification(
- userId=creatorId,
- type=NotificationType.SYSTEM,
- status=NotificationStatus.UNREAD,
- title="Workflow fehlgeschlagen",
- message=f"Workflow '{label or workflowId}' ist fehlgeschlagen: {error[:200]}",
- referenceType="AutoRun",
- referenceId=runId,
- icon="alert-triangle",
- )
- rootInterface.db.recordCreate(
- model_class=UserNotification,
- record=notification.model_dump(),
- )
- logger.info("Created in-app notification for user %s (run %s)", creatorId, runId)
- except Exception as e:
- logger.warning("Failed to create in-app run.failed notification: %s", e)
-
-
-_onRunFailedCallback = None
-
-
-def setOnRunFailedCallback(callback) -> None:
- """Set the callback for run failure notifications (injected by app.py)."""
- global _onRunFailedCallback
- _onRunFailedCallback = callback
-
-
-def _triggerRunFailedSubscription(
- workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None
-) -> None:
- """Trigger the messaging subscription for run failures via injected callback."""
- if _onRunFailedCallback is None:
- return
- try:
- _onRunFailedCallback(
- workflowId=workflowId,
- runId=runId,
- error=error,
- mandateId=mandateId,
- workflowLabel=workflowLabel,
- )
- except Exception as e:
- logger.warning("Failed to trigger run.failed subscription: %s", e)
+from modules.workflowAutomation.engine._runNotifications import ( # noqa: E402 — re-export
+ notifyRunFailed,
+ setOnRunFailedCallback,
+)
# Module-level singleton
diff --git a/modules/workflows/processing/shared/promptGenerationActionsDynamic.py b/modules/workflows/processing/shared/promptGenerationActionsDynamic.py
index dee1cc1f..7415df93 100644
--- a/modules/workflows/processing/shared/promptGenerationActionsDynamic.py
+++ b/modules/workflows/processing/shared/promptGenerationActionsDynamic.py
@@ -41,7 +41,7 @@ def generateDynamicPlanSelectionPrompt(services, context: Any, learningEngine=No
# Add adaptive learning context if available
adaptiveContext = {}
if learningEngine:
- workflowId = getattr(context, 'workflow_id', 'unknown')
+ workflowId = getattr(context, 'workflowId', 'unknown')
userPrompt = extractUserPrompt(context)
adaptiveContext = learningEngine.getAdaptiveContextForActionSelection(workflowId, userPrompt)
@@ -226,7 +226,7 @@ Excludes documents/connections/history entirely.
# Add adaptive learning context if available
adaptiveContext = {}
if learningEngine:
- workflowId = getattr(context, 'workflow_id', 'unknown')
+ workflowId = getattr(context, 'workflowId', 'unknown')
adaptiveContext = learningEngine.getAdaptiveContextForParameters(workflowId, compoundActionName, parametersContext or "")
if adaptiveContext:
diff --git a/tests/integration/mandates/test_createMandate.py b/tests/integration/mandates/test_createMandate.py
index f58f9021..1ad24b75 100644
--- a/tests/integration/mandates/test_createMandate.py
+++ b/tests/integration/mandates/test_createMandate.py
@@ -78,7 +78,7 @@ def _buildInterface(db: _FakeDb) -> AppObjects:
def _stubCopySystemRoles():
"""Avoid touching the bootstrap module (which would need a real DB)."""
with patch(
- "modules.interfaces.interfaceBootstrap.copySystemRolesToMandate",
+ "modules.interfaces.interfaceRbac.copySystemRolesToMandate",
return_value=0,
):
yield
From 4a60086c8002d3768898ddeaf22b35b11c615678 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 9 Jun 2026 09:53:31 +0200
Subject: [PATCH 12/16] cp adapted to 2026 poweron
---
app.py | 4 +-
modules/aicore/aicoreBase.py | 2 +-
modules/aicore/aicoreModelRegistry.py | 2 +-
modules/aicore/aicoreModelSelector.py | 4 +-
modules/aicore/aicorePluginAnthropic.py | 4 +-
modules/aicore/aicorePluginInternal.py | 2 +-
modules/aicore/aicorePluginMistral.py | 2 +-
modules/aicore/aicorePluginOpenai.py | 4 +-
modules/aicore/aicorePluginPerplexity.py | 2 +-
modules/aicore/aicorePluginPrivateLlm.py | 2 +-
modules/aicore/aicorePluginTavily.py | 2 +-
modules/auth/__init__.py | 2 +-
modules/auth/authentication.py | 2 +-
modules/auth/csrf.py | 2 +-
modules/auth/jwtService.py | 2 +-
modules/auth/mfaService.py | 2 +-
modules/auth/oauthConnectTicket.py | 2 +-
modules/auth/oauthProviderConfig.py | 2 +-
modules/auth/tokenManager.py | 2 +-
modules/auth/tokenRefreshMiddleware.py | 2 +-
modules/auth/tokenRefreshService.py | 2 +-
modules/connectors/connectorDbPostgre.py | 2 +-
modules/connectors/connectorMessagingEmail.py | 2 +-
modules/connectors/connectorMessagingSms.py | 2 +-
modules/connectors/connectorPreprocessor.py | 2 +-
modules/connectors/connectorProviderBase.py | 2 +-
.../connectors/connectorProviderClickup.py | 2 +-
modules/connectors/connectorProviderFtp.py | 2 +-
modules/connectors/connectorProviderGoogle.py | 2 +-
.../connectors/connectorProviderInfomaniak.py | 2 +-
modules/connectors/connectorProviderMsft.py | 2 +-
modules/connectors/connectorResolver.py | 2 +-
modules/connectors/connectorTicketsClickup.py | 2 +-
modules/connectors/connectorTicketsJira.py | 2 +-
modules/connectors/connectorTicketsRedmine.py | 2 +-
modules/connectors/connectorVoiceGoogle.py | 2 +-
modules/datamodels/__init__.py | 4 +-
modules/datamodels/datamodelAi.py | 4 +-
modules/datamodels/datamodelAiAudit.py | 2 +-
modules/datamodels/datamodelAudit.py | 2 +-
modules/datamodels/datamodelBackgroundJob.py | 2 +-
modules/datamodels/datamodelBase.py | 2 +-
modules/datamodels/datamodelBilling.py | 2 +-
modules/datamodels/datamodelChat.py | 2 +-
modules/datamodels/datamodelContent.py | 2 +-
modules/datamodels/datamodelDataSource.py | 2 +-
modules/datamodels/datamodelDocref.py | 2 +-
modules/datamodels/datamodelDocument.py | 2 +-
modules/datamodels/datamodelExtraction.py | 4 +-
modules/datamodels/datamodelFeatures.py | 2 +-
modules/datamodels/datamodelFiles.py | 2 +-
modules/datamodels/datamodelInvitation.py | 2 +-
modules/datamodels/datamodelJson.py | 2 +-
modules/datamodels/datamodelKnowledge.py | 2 +-
modules/datamodels/datamodelMembership.py | 2 +-
modules/datamodels/datamodelMessaging.py | 2 +-
modules/datamodels/datamodelNavigation.py | 2 +-
modules/datamodels/datamodelNotification.py | 2 +-
modules/datamodels/datamodelPagination.py | 2 +-
modules/datamodels/datamodelPortTypes.py | 2 +-
modules/datamodels/datamodelRbac.py | 2 +-
modules/datamodels/datamodelSecurity.py | 2 +-
modules/datamodels/datamodelSubscription.py | 2 +-
modules/datamodels/datamodelTickets.py | 2 +-
modules/datamodels/datamodelTools.py | 2 +-
modules/datamodels/datamodelUam.py | 2 +-
modules/datamodels/datamodelUdm.py | 2 +-
modules/datamodels/datamodelUiLanguage.py | 2 +-
modules/datamodels/datamodelUtils.py | 2 +-
modules/datamodels/datamodelViews.py | 2 +-
modules/datamodels/datamodelVoice.py | 2 +-
modules/datamodels/datamodelWorkflow.py | 2 +-
.../datamodels/datamodelWorkflowActions.py | 2 +-
.../datamodels/datamodelWorkflowAutomation.py | 2 +-
modules/datamodels/serviceExceptions.py | 2 +-
modules/dbHelpers/aiAuditLogger.py | 2 +-
modules/dbHelpers/auditLogger.py | 2 +-
.../dbHelpers/dbMultiTenantOptimizations.py | 2 +-
modules/dbHelpers/dbRegistry.py | 2 +-
modules/dbHelpers/fkLabelResolver.py | 2 +-
modules/dbHelpers/fkRegistry.py | 2 +-
modules/dbHelpers/paginationHelpers.py | 2 +-
.../features/commcoach/datamodelCommcoach.py | 2 +-
.../commcoach/interfaceFeatureCommcoach.py | 2 +-
modules/features/commcoach/mainCommcoach.py | 2 +-
.../commcoach/routeFeatureCommcoach.py | 2 +-
.../features/commcoach/serviceCommcoach.py | 2 +-
.../features/commcoach/serviceCommcoachAi.py | 2 +-
.../serviceCommcoachContextRetrieval.py | 2 +-
.../commcoach/serviceCommcoachExport.py | 2 +-
.../commcoach/serviceCommcoachGamification.py | 2 +-
.../commcoach/serviceCommcoachIndexer.py | 2 +-
.../commcoach/serviceCommcoachPersonas.py | 2 +-
.../commcoach/serviceCommcoachScheduler.py | 2 +-
.../commcoach/tests/test_contextRetrieval.py | 2 +-
.../commcoach/tests/test_datamodel.py | 2 +-
.../commcoach/tests/test_mainCommcoach.py | 2 +-
.../commcoach/tests/test_serviceAi.py | 2 +-
.../datamodelFeatureNeutralizer.py | 2 +-
.../interfaceFeatureNeutralizer.py | 2 +-
.../neutralization/mainNeutralization.py | 2 +-
.../neutralization/neutralizePlayground.py | 2 +-
.../neutralization/routeFeatureNeutralizer.py | 2 +-
.../mainServiceNeutralization.py | 2 +-
.../subContentPartAdapter.py | 2 +-
.../serviceNeutralization/subParseString.py | 2 +-
.../serviceNeutralization/subPatterns.py | 4 +-
.../serviceNeutralization/subProcessBinary.py | 2 +-
.../serviceNeutralization/subProcessCommon.py | 2 +-
.../serviceNeutralization/subProcessList.py | 2 +-
.../subProcessPdfInPlace.py | 2 +-
.../serviceNeutralization/subProcessText.py | 2 +-
modules/features/redmine/__init__.py | 2 +-
modules/features/redmine/datamodelRedmine.py | 2 +-
.../redmine/interfaceFeatureRedmine.py | 2 +-
modules/features/redmine/mainRedmine.py | 2 +-
.../features/redmine/routeFeatureRedmine.py | 2 +-
modules/features/redmine/serviceRedmine.py | 2 +-
.../features/redmine/serviceRedmineStats.py | 2 +-
.../redmine/serviceRedmineStatsCache.py | 2 +-
.../features/redmine/serviceRedmineSync.py | 2 +-
.../features/redmine/workflows/__init__.py | 2 +-
.../workflows/methodRedmine/__init__.py | 2 +-
.../methodRedmine/actions/__init__.py | 2 +-
.../methodRedmine/actions/_shared.py | 2 +-
.../methodRedmine/actions/createTicket.py | 2 +-
.../methodRedmine/actions/getStats.py | 2 +-
.../methodRedmine/actions/listRelations.py | 2 +-
.../methodRedmine/actions/listTickets.py | 2 +-
.../methodRedmine/actions/readTicket.py | 2 +-
.../methodRedmine/actions/runSync.py | 2 +-
.../methodRedmine/actions/updateTicket.py | 2 +-
.../workflows/methodRedmine/methodRedmine.py | 2 +-
modules/features/teamsbot/__init__.py | 2 +-
modules/features/teamsbot/bridgeConnector.py | 2 +-
.../features/teamsbot/browserBotConnector.py | 2 +-
modules/features/teamsbot/config.py | 2 +-
.../features/teamsbot/datamodelTeamsbot.py | 2 +-
.../teamsbot/interfaceFeatureTeamsbot.py | 2 +-
modules/features/teamsbot/mainTeamsbot.py | 2 +-
.../features/teamsbot/routeFeatureTeamsbot.py | 2 +-
modules/features/teamsbot/service.py | 2 +-
modules/features/teamsbot/serviceCommands.py | 2 +-
.../features/teamsbot/serviceConversation.py | 2 +-
modules/features/teamsbot/serviceWebSocket.py | 2 +-
.../features/trustee/accounting/__init__.py | 2 +-
.../trustee/accounting/accountingBridge.py | 2 +-
.../accounting/accountingConnectorBase.py | 2 +-
.../trustee/accounting/accountingDataSync.py | 2 +-
.../trustee/accounting/accountingRegistry.py | 2 +-
.../trustee/accounting/connectors/__init__.py | 2 +-
.../connectors/accountingConnectorAbacus.py | 2 +-
.../connectors/accountingConnectorBexio.py | 2 +-
.../connectors/accountingConnectorRma.py | 2 +-
.../trustee/datamodelFeatureTrustee.py | 2 +-
.../trustee/handlerTrusteeAccounting.py | 2 +-
.../trustee/interfaceFeatureTrustee.py | 2 +-
modules/features/trustee/mainTrustee.py | 2 +-
.../features/trustee/routeFeatureTrustee.py | 2 +-
modules/features/trustee/trusteeOntology.py | 2 +-
.../features/trustee/workflows/__init__.py | 2 +-
.../workflows/methodTrustee/__init__.py | 2 +-
.../methodTrustee/actions/extractFromFiles.py | 2 +-
.../methodTrustee/actions/processDocuments.py | 2 +-
.../methodTrustee/actions/queryData.py | 2 +-
.../actions/refreshAccountingData.py | 2 +-
.../methodTrustee/actions/syncToAccounting.py | 2 +-
.../workflows/methodTrustee/methodTrustee.py | 2 +-
modules/features/workspace/__init__.py | 2 +-
.../workspace/datamodelFeatureWorkspace.py | 2 +-
.../workspace/interfaceFeatureWorkspace.py | 2 +-
modules/features/workspace/mainWorkspace.py | 2 +-
.../workspace/routeFeatureWorkspace.py | 2 +-
.../interfaces/_legacyMigrationTelemetry.py | 2 +-
modules/interfaces/interfaceAiObjects.py | 2 +-
modules/interfaces/interfaceBootstrap.py | 2 +-
modules/interfaces/interfaceDbApp.py | 2 +-
modules/interfaces/interfaceDbBilling.py | 2 +-
modules/interfaces/interfaceDbChat.py | 2 +-
modules/interfaces/interfaceDbKnowledge.py | 2 +-
modules/interfaces/interfaceDbManagement.py | 4 +-
modules/interfaces/interfaceDbSubscription.py | 2 +-
modules/interfaces/interfaceFeatures.py | 2 +-
modules/interfaces/interfaceMessaging.py | 2 +-
modules/interfaces/interfaceRbac.py | 2 +-
modules/interfaces/interfaceTableHelpers.py | 2 +-
modules/interfaces/interfaceTicketObjects.py | 2 +-
modules/interfaces/interfaceVoiceObjects.py | 2 +-
.../interfaces/interfaceWorkflowAutomation.py | 2 +-
modules/nodeCatalog/__init__.py | 3 +-
modules/nodeCatalog/_workflowFileSchema.py | 2 +-
modules/nodeCatalog/entryPoints.py | 3 +-
modules/nodeCatalog/nodeAdapter.py | 2 +-
.../nodeCatalog/nodeDefinitions/__init__.py | 3 +-
modules/nodeCatalog/nodeDefinitions/ai.py | 3 +-
.../nodeCatalog/nodeDefinitions/clickup.py | 2 +-
.../nodeCatalog/nodeDefinitions/context.py | 3 +-
.../nodeDefinitions/contextPickerHelp.py | 3 +-
modules/nodeCatalog/nodeDefinitions/data.py | 3 +-
modules/nodeCatalog/nodeDefinitions/email.py | 3 +-
modules/nodeCatalog/nodeDefinitions/file.py | 3 +-
modules/nodeCatalog/nodeDefinitions/flow.py | 3 +-
modules/nodeCatalog/nodeDefinitions/input.py | 3 +-
.../nodeCatalog/nodeDefinitions/redmine.py | 2 +-
.../nodeCatalog/nodeDefinitions/sharepoint.py | 3 +-
.../nodeCatalog/nodeDefinitions/triggers.py | 3 +-
.../nodeCatalog/nodeDefinitions/trustee.py | 3 +-
modules/nodeCatalog/portTypes.py | 2 +-
modules/routes/routeAdmin.py | 2 +-
modules/routes/routeAdminDatabaseHealth.py | 2 +-
modules/routes/routeAdminFeatures.py | 2 +-
modules/routes/routeAdminLogs.py | 2 +-
modules/routes/routeAdminRbacRules.py | 2 +-
.../routes/routeAdminUserAccessOverview.py | 2 +-
modules/routes/routeAttributes.py | 4 +-
modules/routes/routeAudit.py | 2 +-
modules/routes/routeBilling.py | 2 +-
modules/routes/routeClickup.py | 2 +-
modules/routes/routeDataConnections.py | 4 +-
modules/routes/routeDataFiles.py | 15 +++---
modules/routes/routeDataMandates.py | 2 +-
modules/routes/routeDataPrompts.py | 4 +-
modules/routes/routeDataSources.py | 2 +-
modules/routes/routeDataUsers.py | 2 +-
modules/routes/routeGdpr.py | 2 +-
modules/routes/routeI18n.py | 2 +-
modules/routes/routeInvitations.py | 2 +-
modules/routes/routeJobs.py | 2 +-
modules/routes/routeMfa.py | 2 +-
modules/routes/routeNotifications.py | 2 +-
modules/routes/routeRagInventory.py | 2 +-
modules/routes/routeSecurityClickup.py | 2 +-
modules/routes/routeSecurityGoogle.py | 2 +-
modules/routes/routeSecurityInfomaniak.py | 2 +-
modules/routes/routeSecurityLocal.py | 2 +-
modules/routes/routeSecurityMsft.py | 2 +-
modules/routes/routeSharepoint.py | 2 +-
modules/routes/routeStore.py | 2 +-
modules/routes/routeSubscription.py | 2 +-
modules/routes/routeSystem.py | 2 +-
modules/routes/routeTableViews.py | 2 +-
modules/routes/routeUdb.py | 2 +-
modules/routes/routeVoiceGoogle.py | 2 +-
modules/routes/routeVoiceUser.py | 2 +-
modules/routes/routeWorkflowAutomation.py | 2 +-
modules/security/__init__.py | 2 +-
modules/security/passwordUtils.py | 2 +-
modules/security/rbac.py | 2 +-
modules/security/rbacCatalog.py | 2 +-
modules/security/rbacHelpers.py | 2 +-
modules/security/rootAccess.py | 2 +-
modules/serviceCenter/__init__.py | 2 +-
modules/serviceCenter/context.py | 2 +-
modules/serviceCenter/core/__init__.py | 2 +-
modules/serviceCenter/core/flagResolution.py | 2 +-
.../core/serviceSecurity/__init__.py | 2 +-
.../serviceSecurity/mainServiceSecurity.py | 2 +-
.../core/serviceStreaming/__init__.py | 2 +-
.../serviceStreaming/mainServiceStreaming.py | 2 +-
.../core/serviceUtils/__init__.py | 2 +-
.../core/serviceUtils/mainServiceUtils.py | 2 +-
modules/serviceCenter/core/types.py | 2 +-
modules/serviceCenter/registry.py | 2 +-
modules/serviceCenter/resolver.py | 2 +-
modules/serviceCenter/services/__init__.py | 2 +-
.../services/serviceAgent/__init__.py | 2 +-
.../serviceAgent/actionToolAdapter.py | 2 +-
.../services/serviceAgent/agentLoop.py | 2 +-
.../serviceAgent/conversationManager.py | 2 +-
.../serviceAgent/coreTools/__init__.py | 2 +-
.../coreTools/_connectionTools.py | 2 +-
.../coreTools/_crossWorkflowTools.py | 2 +-
.../coreTools/_dataSourceTools.py | 26 ++++-----
.../serviceAgent/coreTools/_documentTools.py | 15 +++---
.../serviceAgent/coreTools/_emailTools.py | 2 +-
.../coreTools/_featureSubAgentTools.py | 28 +++-------
.../serviceAgent/coreTools/_helpers.py | 37 ++++++-------
.../serviceAgent/coreTools/_mediaTools.py | 38 ++++---------
.../serviceAgent/coreTools/_workspaceTools.py | 13 +----
.../serviceAgent/coreTools/registerCore.py | 2 +-
.../services/serviceAgent/datamodelAgent.py | 2 +-
.../serviceAgent/datamodelOntology.py | 2 +-
.../serviceAgent/externalToolRegistry.py | 2 +-
.../services/serviceAgent/featureDataAgent.py | 2 +-
.../serviceAgent/featureDataProvider.py | 2 +-
.../services/serviceAgent/mainServiceAgent.py | 2 +-
.../serviceAgent/ontologyToPromptCompiler.py | 2 +-
.../services/serviceAgent/queryValidator.py | 2 +-
.../services/serviceAgent/sandboxExecutor.py | 14 ++---
.../services/serviceAgent/toolRegistry.py | 2 +-
.../services/serviceAgent/toolboxRegistry.py | 2 +-
.../services/serviceAi/__init__.py | 2 +-
.../services/serviceAi/mainServiceAi.py | 2 +-
.../services/serviceAi/subAiCallLooping.py | 2 +-
.../serviceAi/subContentExtraction.py | 2 +-
.../services/serviceAi/subDocumentIntents.py | 2 +-
.../services/serviceAi/subJsonMerger.py | 2 +-
.../serviceAi/subJsonResponseHandling.py | 2 +-
.../services/serviceAi/subLoopingUseCases.py | 2 +-
.../services/serviceAi/subResponseParsing.py | 2 +-
.../services/serviceAi/subStructureFilling.py | 2 +-
.../serviceAi/subStructureGeneration.py | 2 +-
.../serviceBackgroundJobs/__init__.py | 2 +-
.../mainBackgroundJobService.py | 2 +-
.../services/serviceBilling/__init__.py | 2 +-
.../serviceBilling/billingExhaustedNotify.py | 2 +-
.../serviceBilling/billingWebhookHandler.py | 2 +-
.../serviceBilling/mainServiceBilling.py | 2 +-
.../services/serviceBilling/stripeCheckout.py | 2 +-
.../services/serviceChat/__init__.py | 2 +-
.../services/serviceChat/mainServiceChat.py | 52 +++++++++++++++++-
.../services/serviceClickup/__init__.py | 2 +-
.../serviceClickup/mainServiceClickup.py | 2 +-
.../services/serviceExtraction/__init__.py | 2 +-
.../serviceExtraction/chunking/__init__.py | 2 +-
.../chunking/chunkerImage.py | 2 +-
.../chunking/chunkerStructure.py | 2 +-
.../chunking/chunkerTable.py | 2 +-
.../serviceExtraction/chunking/chunkerText.py | 2 +-
.../serviceExtraction/extractors/__init__.py | 2 +-
.../extractors/extractorAudio.py | 2 +-
.../extractors/extractorBinary.py | 2 +-
.../extractors/extractorContainer.py | 2 +-
.../extractors/extractorCsv.py | 2 +-
.../extractors/extractorDocx.py | 2 +-
.../extractors/extractorEmail.py | 53 +++++++++++++++----
.../extractors/extractorFolder.py | 2 +-
.../extractors/extractorHtml.py | 2 +-
.../extractors/extractorImage.py | 2 +-
.../extractors/extractorJson.py | 2 +-
.../extractors/extractorPdf.py | 2 +-
.../extractors/extractorPptx.py | 2 +-
.../extractors/extractorSql.py | 2 +-
.../extractors/extractorText.py | 2 +-
.../extractors/extractorVideo.py | 2 +-
.../extractors/extractorXlsx.py | 2 +-
.../extractors/extractorXml.py | 2 +-
.../mainServiceExtraction.py | 2 +-
.../serviceExtraction/merging/__init__.py | 2 +-
.../merging/mergerDefault.py | 2 +-
.../serviceExtraction/merging/mergerTable.py | 2 +-
.../serviceExtraction/merging/mergerText.py | 2 +-
.../services/serviceExtraction/subMerger.py | 2 +-
.../services/serviceExtraction/subPipeline.py | 2 +-
.../subPromptBuilderExtraction.py | 2 +-
.../services/serviceExtraction/subRegistry.py | 2 +-
.../services/serviceExtraction/subUtils.py | 2 +-
.../services/serviceGeneration/__init__.py | 2 +-
.../mainServiceGeneration.py | 4 +-
.../serviceGeneration/paths/codePath.py | 2 +-
.../serviceGeneration/paths/documentPath.py | 2 +-
.../serviceGeneration/paths/imagePath.py | 2 +-
.../renderers/_pdfFontFallback.py | 2 +-
.../renderers/codeRendererBaseTemplate.py | 2 +-
.../renderers/documentRendererBaseTemplate.py | 4 +-
.../serviceGeneration/renderers/registry.py | 2 +-
.../renderers/rendererCodeCsv.py | 2 +-
.../renderers/rendererCodeJson.py | 2 +-
.../renderers/rendererCodeXml.py | 2 +-
.../renderers/rendererCsv.py | 2 +-
.../renderers/rendererDocx.py | 2 +-
.../renderers/rendererHtml.py | 2 +-
.../renderers/rendererImage.py | 2 +-
.../renderers/rendererJson.py | 2 +-
.../renderers/rendererMarkdown.py | 2 +-
.../renderers/rendererPdf.py | 4 +-
.../renderers/rendererPptx.py | 2 +-
.../renderers/rendererText.py | 2 +-
.../renderers/rendererXlsx.py | 2 +-
.../serviceGeneration/styleDefaults.py | 2 +-
.../serviceGeneration/subContentGenerator.py | 2 +-
.../serviceGeneration/subContentIntegrator.py | 2 +-
.../serviceGeneration/subDocumentUtility.py | 4 +-
.../serviceGeneration/subJsonSchema.py | 2 +-
.../subPromptBuilderGeneration.py | 2 +-
.../subStructureGenerator.py | 2 +-
.../services/serviceKnowledge/__init__.py | 2 +-
.../services/serviceKnowledge/_buildTree.py | 2 +-
.../services/serviceKnowledge/costEstimate.py | 2 +-
.../serviceKnowledge/mainServiceKnowledge.py | 2 +-
.../services/serviceKnowledge/ragLimits.py | 2 +-
.../subConnectorIngestConsumer.py | 2 +-
.../subConnectorSyncClickup.py | 2 +-
.../subConnectorSyncGdrive.py | 2 +-
.../serviceKnowledge/subConnectorSyncGmail.py | 2 +-
.../subConnectorSyncKdrive.py | 2 +-
.../subConnectorSyncOutlook.py | 2 +-
.../subConnectorSyncSharepoint.py | 2 +-
.../serviceKnowledge/subFeatureBootstrap.py | 2 +-
.../services/serviceKnowledge/subPreScan.py | 2 +-
.../services/serviceKnowledge/subTextClean.py | 2 +-
.../serviceKnowledge/subWalkerHelpers.py | 2 +-
.../services/serviceKnowledge/udbNodes.py | 2 +-
.../services/serviceMessaging/__init__.py | 2 +-
.../serviceMessaging/mainServiceMessaging.py | 2 +-
.../subscriptions/__init__.py | 2 +-
.../subSubscriptionSystemErrors.py | 2 +-
...SubscriptionWorkflowAutomationRunFailed.py | 2 +-
.../services/serviceSharepoint/__init__.py | 2 +-
.../mainServiceSharepoint.py | 2 +-
.../enterpriseRenewalScheduler.py | 2 +-
.../mainServiceSubscription.py | 2 +-
.../serviceSubscription/stripeBootstrap.py | 2 +-
.../services/serviceTicket/__init__.py | 2 +-
.../serviceTicket/mainServiceTicket.py | 2 +-
.../services/serviceWeb/__init__.py | 2 +-
.../services/serviceWeb/mainServiceWeb.py | 2 +-
modules/shared/__init__.py | 2 +-
modules/shared/attributeUtils.py | 2 +-
modules/shared/callbackRegistry.py | 2 +-
modules/shared/configuration.py | 4 +-
modules/shared/dateRange.py | 2 +-
modules/shared/debugLogger.py | 2 +-
modules/shared/documentUtils.py | 2 +-
modules/shared/eventManagement.py | 2 +-
modules/shared/eventManager.py | 2 +-
modules/shared/featureDiscovery.py | 2 +-
modules/shared/frontendTypes.py | 2 +-
modules/shared/httpResilience.py | 2 +-
modules/shared/i18nRegistry.py | 2 +-
modules/shared/jsonUtils.py | 2 +-
modules/shared/mandateNameUtils.py | 2 +-
modules/shared/progressLogger.py | 2 +-
modules/shared/stripeClient.py | 2 +-
modules/shared/systemComponentRegistry.py | 3 +-
modules/shared/timeUtils.py | 4 +-
modules/shared/voiceCatalog.py | 2 +-
modules/shared/workflowArtifactVisibility.py | 3 +-
modules/shared/workflowState.py | 2 +-
modules/system/__init__.py | 2 +-
modules/system/databaseHealth.py | 2 +-
modules/system/databaseMigration.py | 2 +-
modules/system/gdprDeletion.py | 2 +-
modules/system/i18nBootSync.py | 2 +-
modules/system/mainSystem.py | 2 +-
modules/system/notifyMandateAdmins.py | 2 +-
modules/system/registry.py | 2 +-
modules/workflowAutomation/agentTools.py | 2 +-
.../editor/_valueKindResolver.py | 3 +-
.../editor/adapterValidator.py | 2 +-
.../editor/conditionOperators.py | 3 +-
.../workflowAutomation/editor/nodeRegistry.py | 2 +-
.../workflowAutomation/editor/switchOutput.py | 3 +-
.../editor/upstreamPathsService.py | 3 +-
modules/workflowAutomation/engine/__init__.py | 3 +-
.../engine/_runNotifications.py | 3 +-
.../engine/clickupTaskUpdateMerge.py | 3 +-
.../engine/executionEngine.py | 3 +-
.../engine/executors/__init__.py | 3 +-
.../engine/executors/actionNodeExecutor.py | 3 +-
.../engine/executors/dataExecutor.py | 3 +-
.../engine/executors/flowExecutor.py | 3 +-
.../engine/executors/inputExecutor.py | 3 +-
.../engine/executors/ioExecutor.py | 3 +-
.../engine/executors/triggerExecutor.py | 3 +-
.../engine/featureInstanceRefMigration.py | 3 +-
.../workflowAutomation/engine/graphUtils.py | 3 +-
.../engine/pickNotPushMigration.py | 3 +-
.../workflowAutomation/engine/runEnvelope.py | 3 +-
.../engine/runFileLogger.py | 3 +-
.../workflowAutomation/engine/scheduleCron.py | 3 +-
.../engine/udmUpstreamShapes.py | 3 +-
modules/workflowAutomation/helpers.py | 2 +-
.../mainWorkflowAutomation.py | 10 ++--
.../workflowAutomation/scheduler/__init__.py | 3 +-
.../scheduler/emailPoller.py | 2 +-
.../scheduler/mainScheduler.py | 2 +-
.../methods/_actionSignatureValidator.py | 2 +-
.../workflows/methods/methodAi/__init__.py | 2 +-
modules/workflows/methods/methodAi/_common.py | 2 +-
.../methods/methodAi/actions/__init__.py | 2 +-
.../methods/methodAi/actions/consolidate.py | 2 +-
.../methodAi/actions/convertDocument.py | 2 +-
.../methods/methodAi/actions/generateCode.py | 2 +-
.../methodAi/actions/generateDocument.py | 2 +-
.../methods/methodAi/actions/process.py | 2 +-
.../methodAi/actions/summarizeDocument.py | 2 +-
.../methodAi/actions/translateDocument.py | 2 +-
.../methods/methodAi/actions/webResearch.py | 2 +-
.../methods/methodAi/helpers/__init__.py | 2 +-
.../methods/methodAi/helpers/csvProcessing.py | 2 +-
.../workflows/methods/methodAi/methodAi.py | 2 +-
modules/workflows/methods/methodBase.py | 4 +-
.../methods/methodClickup/__init__.py | 2 +-
.../methods/methodClickup/actions/__init__.py | 2 +-
.../methodClickup/actions/create_task.py | 2 +-
.../methods/methodClickup/actions/get_task.py | 2 +-
.../methodClickup/actions/list_fields.py | 2 +-
.../methodClickup/actions/list_tasks.py | 2 +-
.../methodClickup/actions/search_tasks.py | 2 +-
.../methodClickup/actions/update_task.py | 2 +-
.../actions/upload_attachment.py | 2 +-
.../methods/methodClickup/helpers/__init__.py | 2 +-
.../methodClickup/helpers/connection.py | 2 +-
.../methodClickup/helpers/pathparse.py | 2 +-
.../methods/methodClickup/methodClickup.py | 2 +-
.../methods/methodContext/__init__.py | 2 +-
.../methods/methodContext/actions/__init__.py | 2 +-
.../methodContext/actions/extractContent.py | 2 +-
.../methodContext/actions/filterContext.py | 2 +-
.../methodContext/actions/getDocumentIndex.py | 2 +-
.../methodContext/actions/mergeContext.py | 2 +-
.../methodContext/actions/neutralizeData.py | 2 +-
.../methodContext/actions/setContext.py | 2 +-
.../methodContext/actions/transformContext.py | 2 +-
.../actions/triggerPreprocessingServer.py | 2 +-
.../methods/methodContext/contextEnvelope.py | 3 +-
.../methods/methodContext/helpers/__init__.py | 2 +-
.../methodContext/helpers/documentIndex.py | 2 +-
.../methodContext/helpers/formatting.py | 2 +-
.../methods/methodContext/methodContext.py | 2 +-
.../workflows/methods/methodFile/__init__.py | 2 +-
.../methods/methodFile/actions/__init__.py | 2 +-
.../methods/methodFile/actions/create.py | 2 +-
.../methods/methodFile/methodFile.py | 2 +-
.../workflows/methods/methodJira/__init__.py | 2 +-
.../methods/methodJira/actions/__init__.py | 2 +-
.../methods/methodJira/actions/connectJira.py | 2 +-
.../methodJira/actions/createCsvContent.py | 2 +-
.../methodJira/actions/createExcelContent.py | 2 +-
.../methodJira/actions/exportTicketsAsJson.py | 2 +-
.../actions/importTicketsFromJson.py | 2 +-
.../methodJira/actions/mergeTicketData.py | 2 +-
.../methodJira/actions/parseCsvContent.py | 2 +-
.../methodJira/actions/parseExcelContent.py | 2 +-
.../methods/methodJira/helpers/__init__.py | 2 +-
.../methodJira/helpers/adfConverter.py | 2 +-
.../methodJira/helpers/documentParsing.py | 2 +-
.../methods/methodJira/methodJira.py | 2 +-
.../methods/methodOutlook/__init__.py | 2 +-
.../methods/methodOutlook/actions/__init__.py | 2 +-
.../composeAndDraftEmailWithContext.py | 2 +-
.../methodOutlook/actions/readEmails.py | 2 +-
.../methodOutlook/actions/searchEmails.py | 2 +-
.../methodOutlook/actions/sendDraftEmail.py | 2 +-
.../methods/methodOutlook/helpers/__init__.py | 2 +-
.../methodOutlook/helpers/connection.py | 2 +-
.../methodOutlook/helpers/emailProcessing.py | 2 +-
.../methodOutlook/helpers/folderManagement.py | 2 +-
.../methods/methodOutlook/methodOutlook.py | 2 +-
.../methods/methodSharepoint/__init__.py | 2 +-
.../methodSharepoint/actions/__init__.py | 2 +-
.../actions/analyzeFolderUsage.py | 2 +-
.../methodSharepoint/actions/copyFile.py | 2 +-
.../actions/downloadFileByPath.py | 2 +-
.../actions/findDocumentPath.py | 2 +-
.../methodSharepoint/actions/findSiteByUrl.py | 2 +-
.../methodSharepoint/actions/listDocuments.py | 2 +-
.../methodSharepoint/actions/readDocuments.py | 2 +-
.../actions/uploadDocument.py | 2 +-
.../methodSharepoint/actions/uploadFile.py | 2 +-
.../methodSharepoint/helpers/__init__.py | 2 +-
.../methodSharepoint/helpers/apiClient.py | 2 +-
.../methodSharepoint/helpers/connection.py | 2 +-
.../helpers/documentParsing.py | 2 +-
.../helpers/pathProcessing.py | 2 +-
.../methodSharepoint/helpers/siteDiscovery.py | 2 +-
.../methodSharepoint/methodSharepoint.py | 2 +-
.../workflows/processing/adaptive/__init__.py | 2 +-
.../adaptive/adaptiveLearningEngine.py | 2 +-
.../processing/adaptive/contentValidator.py | 2 +-
.../processing/adaptive/learningEngine.py | 2 +-
.../processing/adaptive/progressTracker.py | 2 +-
modules/workflows/processing/core/__init__.py | 2 +-
.../processing/core/actionExecutor.py | 2 +-
.../processing/core/messageCreator.py | 2 +-
.../workflows/processing/core/taskPlanner.py | 4 +-
.../workflows/processing/core/validator.py | 2 +-
.../workflows/processing/modes/__init__.py | 2 +-
.../processing/modes/modeAutomation.py | 2 +-
.../workflows/processing/modes/modeBase.py | 2 +-
.../workflows/processing/modes/modeDynamic.py | 2 +-
.../workflows/processing/shared/__init__.py | 2 +-
.../processing/shared/executionState.py | 4 +-
.../processing/shared/methodDiscovery.py | 2 +-
.../processing/shared/parameterValidation.py | 2 +-
.../processing/shared/placeholderFactory.py | 2 +-
.../shared/promptGenerationActionsDynamic.py | 2 +-
.../shared/promptGenerationTaskplan.py | 2 +-
.../workflows/processing/workflowProcessor.py | 2 +-
modules/workflows/workflowManager.py | 2 +-
scripts/script_analyze_function_imports.py | 2 +-
scripts/script_analyze_imports.py | 2 +-
scripts/script_db_export_migration.py | 2 +-
scripts/script_generate_container_diagram.py | 2 +-
scripts/script_generate_import_diagram.py | 2 +-
.../script_migrate_feature_instance_refs.py | 2 +-
scripts/script_remove_redundant_imports.py | 2 +-
.../script_security_encrypt_all_env_files.py | 2 +-
.../script_security_encrypt_config_value.py | 2 +-
.../script_security_generate_master_keys.py | 2 +-
scripts/script_stats_durations_from_log.py | 2 +-
scripts/script_stats_get_codelines.py | 4 +-
scripts/script_stats_showUnusedFunctions.py | 2 +-
tests/__init__.py | 2 +-
tests/conftest.py | 2 +-
tests/demo/test_pwg_demo_bootstrap.py | 2 +-
tests/eval/__init__.py | 2 +-
tests/eval/fakeFeatureDataProvider.py | 2 +-
tests/eval/runTrusteeBenchmark.py | 2 +-
tests/fixtures/loadRedmineSnapshot.py | 2 +-
tests/fixtures/trusteeBenchmark/__init__.py | 2 +-
.../loadTrusteeBenchmarkFixture.py | 2 +-
tests/functional/__init__.py | 2 +-
tests/functional/test01_ai_model_selection.py | 2 +-
tests/functional/test02_ai_models.py | 2 +-
tests/functional/test03_ai_operations.py | 2 +-
tests/functional/test04_ai_behavior.py | 2 +-
tests/functional/test07_json_merge.py | 2 +-
tests/functional/test08_json_finalization.py | 2 +-
tests/functional/test12_json_split_merge.py | 2 +-
.../functional/test13_json_completion_cuts.py | 2 +-
.../test14_json_continuation_context.py | 2 +-
tests/functional/test_kpi_full.py | 2 +-
tests/functional/test_kpi_incomplete.py | 2 +-
tests/functional/test_kpi_path.py | 2 +-
tests/integration/__init__.py | 2 +-
tests/integration/automation2/__init__.py | 3 +-
.../test_pick_not_push_migration_v2.py | 3 +-
.../extraction/test_extract_udm_pipeline.py | 2 +-
.../mandates/test_createMandate.py | 2 +-
.../mandates/test_provisionMandate.py | 2 +-
.../mandates/test_updateMandate.py | 2 +-
tests/integration/rbac/__init__.py | 2 +-
.../rbac/test_platform_admin_flag.py | 2 +-
tests/integration/rbac/test_rbac_database.py | 2 +-
tests/integration/trustee/__init__.py | 2 +-
.../trustee/test_spesenbelege_workflow_e2e.py | 2 +-
tests/integration/users/test_updateUser.py | 2 +-
...xecute_graph_loop_aggregate_consolidate.py | 3 +-
.../workflows/test_workflow_execution.py | 2 +-
.../test_allowed_models_whitelist.py | 2 +-
.../test_inline_image_paragraph.py | 2 +-
.../test_large_document_render.py | 2 +-
.../test_layout_primitives.py | 2 +-
.../test_md_to_json_consolidation.py | 2 +-
.../serviceGeneration/test_style_resolver.py | 2 +-
tests/test_dateRange.py | 2 +-
tests/test_service_redmine_orphans.py | 2 +-
tests/test_service_redmine_stats.py | 2 +-
tests/test_service_redmine_stats_cache.py | 2 +-
tests/unit/__init__.py | 2 +-
.../test_aicorePluginOpenai_temperature.py | 2 +-
tests/unit/auth/test_mfaService.py | 2 +-
.../test_connectorDbPostgre_failLoud.py | 2 +-
.../test_connectorDbPostgre_pool.py | 2 +-
.../unit/connectors/test_connectorResolver.py | 3 +-
.../test_connectorVoiceGoogle_sttHelpers.py | 3 +-
tests/unit/datamodels/test_docref.py | 2 +-
tests/unit/datamodels/test_udm_bridge.py | 2 +-
tests/unit/datamodels/test_udm_models.py | 2 +-
tests/unit/datamodels/test_workflow_models.py | 2 +-
.../test_trustee_template_workflows.py | 3 +-
...test_accountingConnectorAbacus_balances.py | 2 +-
.../test_accountingConnectorBexio_balances.py | 2 +-
.../test_accountingConnectorRma_balances.py | 2 +-
.../test_accountingDataSync_balances.py | 2 +-
.../test_action_node_connection_provenance.py | 3 +-
.../graphicalEditor/test_adapter_validator.py | 2 +-
.../test_condition_operator_catalog.py | 3 +-
...est_featureInstanceRef_node_definitions.py | 2 +-
.../unit/graphicalEditor/test_node_adapter.py | 2 +-
.../graphicalEditor/test_portTypes_catalog.py | 3 +-
.../test_port_schema_recursive.py | 3 +-
.../test_resolve_value_kind.py | 3 +-
.../test_upstream_paths_and_graph_schema.py | 3 +-
tests/unit/interfaces/test_folderRbac.py | 2 +-
.../test_action_signature_validator.py | 2 +-
.../test_trustee_schema_compliance.py | 3 +-
tests/unit/rbac/__init__.py | 2 +-
tests/unit/rbac/test_rbac_bootstrap.py | 2 +-
tests/unit/rbac/test_rbac_permissions.py | 2 +-
tests/unit/routes/test_folder_crud.py | 2 +-
tests/unit/scripts/__init__.py | 2 +-
.../test_migrate_feature_instance_refs.py | 2 +-
.../test_action_tool_adapter_typed.py | 2 +-
.../test_agentTrace_repairCounters.py | 2 +-
.../serviceAgent/test_field_neutralization.py | 2 +-
.../serviceAgent/test_workflow_tools_crud.py | 2 +-
tests/unit/services/test_bootstrap_clickup.py | 2 +-
tests/unit/services/test_bootstrap_gdrive.py | 2 +-
tests/unit/services/test_bootstrap_gmail.py | 2 +-
tests/unit/services/test_bootstrap_outlook.py | 2 +-
.../services/test_bootstrap_sharepoint.py | 2 +-
tests/unit/services/test_clean_email_body.py | 2 +-
tests/unit/services/test_connection_purge.py | 2 +-
.../test_extraction_merge_strategy.py | 2 +-
.../services/test_featureDataAgent_schema.py | 2 +-
.../services/test_ingestion_hash_stability.py | 2 +-
.../services/test_json_extraction_merging.py | 2 +-
.../test_knowledge_ingest_consumer.py | 2 +-
tests/unit/services/test_queryValidator.py | 2 +-
.../unit/services/test_renderer_pdf_smoke.py | 2 +-
tests/unit/services/test_trusteeOntology.py | 2 +-
tests/unit/shared/test_mandateNameUtils.py | 2 +-
tests/unit/teamsbot/test_directorPrompts.py | 2 +-
tests/unit/utils/test_json_utils.py | 2 +-
.../workflow/test_flow_executor_conditions.py | 3 +-
.../workflow/test_switch_filtered_output.py | 3 +-
tests/unit/workflow/test_trusteeQueryData.py | 2 +-
.../unit/workflow/test_workflowFileSchema.py | 2 +-
.../test_featureInstanceRefMigration.py | 3 +-
.../workflows/test_parameterValidation.py | 2 +-
tests/unit/workflows/test_state_management.py | 2 +-
tests/unit/workflows/test_trigger_executor.py | 3 +-
.../test_architecture_validation.py | 2 +-
.../test_featureCatalogLabels_i18n.py | 2 +-
707 files changed, 940 insertions(+), 854 deletions(-)
diff --git a/app.py b/app.py
index 31085e6b..68341361 100644
--- a/app.py
+++ b/app.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import os
import sys
@@ -917,4 +917,4 @@ if __name__ == "__main__":
], check=True)
except ImportError:
import uvicorn
- uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=2)
\ No newline at end of file
+ uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=2)
diff --git a/modules/aicore/aicoreBase.py b/modules/aicore/aicoreBase.py
index 0908c40d..a91cef63 100644
--- a/modules/aicore/aicoreBase.py
+++ b/modules/aicore/aicoreBase.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Base connector interface for AI connectors.
diff --git a/modules/aicore/aicoreModelRegistry.py b/modules/aicore/aicoreModelRegistry.py
index 8c57e0c4..a78229bc 100644
--- a/modules/aicore/aicoreModelRegistry.py
+++ b/modules/aicore/aicoreModelRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Dynamic model registry that collects models from all AI connectors.
diff --git a/modules/aicore/aicoreModelSelector.py b/modules/aicore/aicoreModelSelector.py
index f51d6cec..f87b8d4c 100644
--- a/modules/aicore/aicoreModelSelector.py
+++ b/modules/aicore/aicoreModelSelector.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Simplified model selection based on model properties and priority-based sorting.
@@ -323,4 +323,4 @@ class ModelSelector:
# Global model selector instance
-modelSelector = ModelSelector()
\ No newline at end of file
+modelSelector = ModelSelector()
diff --git a/modules/aicore/aicorePluginAnthropic.py b/modules/aicore/aicorePluginAnthropic.py
index 4e873511..00fdd694 100644
--- a/modules/aicore/aicorePluginAnthropic.py
+++ b/modules/aicore/aicorePluginAnthropic.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import base64
import json
@@ -862,4 +862,4 @@ def _convertToolsToAnthropicFormat(openaiTools: List[Dict[str, Any]]) -> List[Di
"description": fn.get("description", ""),
"input_schema": fn.get("parameters", {"type": "object", "properties": {}})
})
- return anthropicTools
\ No newline at end of file
+ return anthropicTools
diff --git a/modules/aicore/aicorePluginInternal.py b/modules/aicore/aicorePluginInternal.py
index 59854629..1e39cb6c 100644
--- a/modules/aicore/aicorePluginInternal.py
+++ b/modules/aicore/aicorePluginInternal.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
from typing import List
diff --git a/modules/aicore/aicorePluginMistral.py b/modules/aicore/aicorePluginMistral.py
index a9805195..8e32c67b 100644
--- a/modules/aicore/aicorePluginMistral.py
+++ b/modules/aicore/aicorePluginMistral.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import json
diff --git a/modules/aicore/aicorePluginOpenai.py b/modules/aicore/aicorePluginOpenai.py
index 78f8ba26..3667d742 100644
--- a/modules/aicore/aicorePluginOpenai.py
+++ b/modules/aicore/aicorePluginOpenai.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import json
@@ -730,4 +730,4 @@ class AiOpenai(BaseConnectorAi):
content="",
success=False,
error=f"Error during image generation: {str(e)}",
- )
\ No newline at end of file
+ )
diff --git a/modules/aicore/aicorePluginPerplexity.py b/modules/aicore/aicorePluginPerplexity.py
index dd13deb1..9af3511c 100644
--- a/modules/aicore/aicorePluginPerplexity.py
+++ b/modules/aicore/aicorePluginPerplexity.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import httpx
diff --git a/modules/aicore/aicorePluginPrivateLlm.py b/modules/aicore/aicorePluginPrivateLlm.py
index 988ae9e4..b96a1c4a 100644
--- a/modules/aicore/aicorePluginPrivateLlm.py
+++ b/modules/aicore/aicorePluginPrivateLlm.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
AI Connector for PowerOn Private-LLM Service.
diff --git a/modules/aicore/aicorePluginTavily.py b/modules/aicore/aicorePluginTavily.py
index 80f9d5b4..09cac0d0 100644
--- a/modules/aicore/aicorePluginTavily.py
+++ b/modules/aicore/aicorePluginTavily.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Tavily web search class.
"""
diff --git a/modules/auth/__init__.py b/modules/auth/__init__.py
index 0a485767..2ed3fa13 100644
--- a/modules/auth/__init__.py
+++ b/modules/auth/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Authentication and authorization modules for routes and services.
diff --git a/modules/auth/authentication.py b/modules/auth/authentication.py
index d641d659..eb582fac 100644
--- a/modules/auth/authentication.py
+++ b/modules/auth/authentication.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Authentication module for backend API.
diff --git a/modules/auth/csrf.py b/modules/auth/csrf.py
index bac4b0c3..c9193429 100644
--- a/modules/auth/csrf.py
+++ b/modules/auth/csrf.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CSRF Protection Middleware for PowerOn Gateway
diff --git a/modules/auth/jwtService.py b/modules/auth/jwtService.py
index 04071053..342c2d0b 100644
--- a/modules/auth/jwtService.py
+++ b/modules/auth/jwtService.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
JWT Service
diff --git a/modules/auth/mfaService.py b/modules/auth/mfaService.py
index 3987eab9..e4841082 100644
--- a/modules/auth/mfaService.py
+++ b/modules/auth/mfaService.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
MFA (Multi-Factor Authentication) Service.
diff --git a/modules/auth/oauthConnectTicket.py b/modules/auth/oauthConnectTicket.py
index f54187cb..af3908f7 100644
--- a/modules/auth/oauthConnectTicket.py
+++ b/modules/auth/oauthConnectTicket.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Short-lived signed tickets for OAuth data-connection popups.
diff --git a/modules/auth/oauthProviderConfig.py b/modules/auth/oauthProviderConfig.py
index b6c482e7..713e356e 100644
--- a/modules/auth/oauthProviderConfig.py
+++ b/modules/auth/oauthProviderConfig.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft)."""
diff --git a/modules/auth/tokenManager.py b/modules/auth/tokenManager.py
index e854f563..3d235da4 100644
--- a/modules/auth/tokenManager.py
+++ b/modules/auth/tokenManager.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Token Manager Service
diff --git a/modules/auth/tokenRefreshMiddleware.py b/modules/auth/tokenRefreshMiddleware.py
index 84d2feae..a712fe6c 100644
--- a/modules/auth/tokenRefreshMiddleware.py
+++ b/modules/auth/tokenRefreshMiddleware.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Token Refresh Middleware for PowerOn Gateway
diff --git a/modules/auth/tokenRefreshService.py b/modules/auth/tokenRefreshService.py
index bc471e6f..7cb3ddeb 100644
--- a/modules/auth/tokenRefreshService.py
+++ b/modules/auth/tokenRefreshService.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Token Refresh Service for PowerOn Gateway
diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py
index 493a3862..11b406ad 100644
--- a/modules/connectors/connectorDbPostgre.py
+++ b/modules/connectors/connectorDbPostgre.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import contextvars
import copy
diff --git a/modules/connectors/connectorMessagingEmail.py b/modules/connectors/connectorMessagingEmail.py
index 57dd22e7..31a55e2b 100644
--- a/modules/connectors/connectorMessagingEmail.py
+++ b/modules/connectors/connectorMessagingEmail.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Azure Communication Services Email Connector
diff --git a/modules/connectors/connectorMessagingSms.py b/modules/connectors/connectorMessagingSms.py
index 36491b55..41ace8f2 100644
--- a/modules/connectors/connectorMessagingSms.py
+++ b/modules/connectors/connectorMessagingSms.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Twilio SMS Connector
diff --git a/modules/connectors/connectorPreprocessor.py b/modules/connectors/connectorPreprocessor.py
index 189b6e2b..88bf56af 100644
--- a/modules/connectors/connectorPreprocessor.py
+++ b/modules/connectors/connectorPreprocessor.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Preprocessor connector for executing SQL queries via HTTP API.
diff --git a/modules/connectors/connectorProviderBase.py b/modules/connectors/connectorProviderBase.py
index 29062386..5fd65d0e 100644
--- a/modules/connectors/connectorProviderBase.py
+++ b/modules/connectors/connectorProviderBase.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Abstract base classes for the Provider-Connector architecture (1:n).
diff --git a/modules/connectors/connectorProviderClickup.py b/modules/connectors/connectorProviderClickup.py
index 2a2f2ba1..60e69c2a 100644
--- a/modules/connectors/connectorProviderClickup.py
+++ b/modules/connectors/connectorProviderClickup.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows).
diff --git a/modules/connectors/connectorProviderFtp.py b/modules/connectors/connectorProviderFtp.py
index b6477f82..c59915c5 100644
--- a/modules/connectors/connectorProviderFtp.py
+++ b/modules/connectors/connectorProviderFtp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""FTP/SFTP ProviderConnector stub.
diff --git a/modules/connectors/connectorProviderGoogle.py b/modules/connectors/connectorProviderGoogle.py
index acce4935..f9c4099f 100644
--- a/modules/connectors/connectorProviderGoogle.py
+++ b/modules/connectors/connectorProviderGoogle.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Google ProviderConnector -- Drive and Gmail via Google OAuth."""
diff --git a/modules/connectors/connectorProviderInfomaniak.py b/modules/connectors/connectorProviderInfomaniak.py
index 661fdb64..33727e76 100644
--- a/modules/connectors/connectorProviderInfomaniak.py
+++ b/modules/connectors/connectorProviderInfomaniak.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT.
diff --git a/modules/connectors/connectorProviderMsft.py b/modules/connectors/connectorProviderMsft.py
index 266f9deb..4d0cfb43 100644
--- a/modules/connectors/connectorProviderMsft.py
+++ b/modules/connectors/connectorProviderMsft.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Microsoft ProviderConnector -- one MSFT connection serves SharePoint, Outlook, Teams, OneDrive.
diff --git a/modules/connectors/connectorResolver.py b/modules/connectors/connectorResolver.py
index a6b559a0..9fbf13ca 100644
--- a/modules/connectors/connectorResolver.py
+++ b/modules/connectors/connectorResolver.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ConnectorResolver -- resolves a connectionId to the correct ProviderConnector and ServiceAdapter.
diff --git a/modules/connectors/connectorTicketsClickup.py b/modules/connectors/connectorTicketsClickup.py
index bb43ceac..4f6f3ec6 100644
--- a/modules/connectors/connectorTicketsClickup.py
+++ b/modules/connectors/connectorTicketsClickup.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp connector for CRUD operations (compatible with TicketInterface).
diff --git a/modules/connectors/connectorTicketsJira.py b/modules/connectors/connectorTicketsJira.py
index bfc9a370..40b7a630 100644
--- a/modules/connectors/connectorTicketsJira.py
+++ b/modules/connectors/connectorTicketsJira.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Jira connector for CRUD operations (neutralized to generic ticket interface).
diff --git a/modules/connectors/connectorTicketsRedmine.py b/modules/connectors/connectorTicketsRedmine.py
index 9caff47d..391d86c0 100644
--- a/modules/connectors/connectorTicketsRedmine.py
+++ b/modules/connectors/connectorTicketsRedmine.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Redmine REST connector.
diff --git a/modules/connectors/connectorVoiceGoogle.py b/modules/connectors/connectorVoiceGoogle.py
index 7ae8e54b..590fd26b 100644
--- a/modules/connectors/connectorVoiceGoogle.py
+++ b/modules/connectors/connectorVoiceGoogle.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Google Cloud Speech-to-Text and Translation Connector
diff --git a/modules/datamodels/__init__.py b/modules/datamodels/__init__.py
index 40adbebb..ad412b8e 100644
--- a/modules/datamodels/__init__.py
+++ b/modules/datamodels/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unified modules.datamodels package.
@@ -14,4 +14,4 @@ from . import datamodelChat as chat
from . import datamodelFiles as files
from . import datamodelVoice as voice
from . import datamodelUtils as utils
-from . import jsonContinuation
\ No newline at end of file
+from . import jsonContinuation
diff --git a/modules/datamodels/datamodelAi.py b/modules/datamodels/datamodelAi.py
index cd481c9a..a1d6994c 100644
--- a/modules/datamodels/datamodelAi.py
+++ b/modules/datamodels/datamodelAi.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Optional, List, Dict, Any, Callable, TYPE_CHECKING, Tuple
from pydantic import BaseModel, Field, ConfigDict
@@ -351,4 +351,4 @@ class CodeContentPromptArgs(BaseModel):
class CodeStructurePromptArgs(BaseModel):
"""Type-safe arguments for code structure prompt builder."""
userPrompt: str
- contentParts: List[ContentPart] = Field(default_factory=list)
\ No newline at end of file
+ contentParts: List[ContentPart] = Field(default_factory=list)
diff --git a/modules/datamodels/datamodelAiAudit.py b/modules/datamodels/datamodelAiAudit.py
index f78ecd23..de9942ae 100644
--- a/modules/datamodels/datamodelAiAudit.py
+++ b/modules/datamodels/datamodelAiAudit.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""AI Audit Log data model for Compliance & AI-Datenfluss tracking.
diff --git a/modules/datamodels/datamodelAudit.py b/modules/datamodels/datamodelAudit.py
index 85cdfbf2..577380aa 100644
--- a/modules/datamodels/datamodelAudit.py
+++ b/modules/datamodels/datamodelAudit.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Audit Log Data Model for database-based audit logging.
diff --git a/modules/datamodels/datamodelBackgroundJob.py b/modules/datamodels/datamodelBackgroundJob.py
index 809fb994..4f769186 100644
--- a/modules/datamodels/datamodelBackgroundJob.py
+++ b/modules/datamodels/datamodelBackgroundJob.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Background job models: generic, reusable infrastructure for long-running tasks.
diff --git a/modules/datamodels/datamodelBase.py b/modules/datamodels/datamodelBase.py
index 8fc4fa44..7e5c29f1 100644
--- a/modules/datamodels/datamodelBase.py
+++ b/modules/datamodels/datamodelBase.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Base Pydantic model with system-managed fields (DB + API + UI metadata)."""
diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py
index 78024ce1..04fc5578 100644
--- a/modules/datamodels/datamodelBilling.py
+++ b/modules/datamodels/datamodelBilling.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics."""
diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py
index 7b4e21eb..32cfc2f1 100644
--- a/modules/datamodels/datamodelChat.py
+++ b/modules/datamodels/datamodelChat.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Chat models: ChatWorkflow, ChatMessage, ChatLog, ChatDocument."""
diff --git a/modules/datamodels/datamodelContent.py b/modules/datamodels/datamodelContent.py
index c28036cf..d3685460 100644
--- a/modules/datamodels/datamodelContent.py
+++ b/modules/datamodels/datamodelContent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Content Object data models for the container and content extraction pipeline.
diff --git a/modules/datamodels/datamodelDataSource.py b/modules/datamodels/datamodelDataSource.py
index 1cfaa933..85fcda23 100644
--- a/modules/datamodels/datamodelDataSource.py
+++ b/modules/datamodels/datamodelDataSource.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""DataSource and ExternalEntry models for external data integration.
diff --git a/modules/datamodels/datamodelDocref.py b/modules/datamodels/datamodelDocref.py
index f4ce09aa..3808f900 100644
--- a/modules/datamodels/datamodelDocref.py
+++ b/modules/datamodels/datamodelDocref.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Document reference models for typed document references in workflows.
diff --git a/modules/datamodels/datamodelDocument.py b/modules/datamodels/datamodelDocument.py
index e34c82ff..66014233 100644
--- a/modules/datamodels/datamodelDocument.py
+++ b/modules/datamodels/datamodelDocument.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List, Optional, Literal, Union
from pydantic import BaseModel, Field, field_serializer
diff --git a/modules/datamodels/datamodelExtraction.py b/modules/datamodels/datamodelExtraction.py
index 38fd1d27..a9559de0 100644
--- a/modules/datamodels/datamodelExtraction.py
+++ b/modules/datamodels/datamodelExtraction.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List, Optional, Literal
from pydantic import BaseModel, Field
@@ -112,4 +112,4 @@ class ExtractionOptions(BaseModel):
# Additional processing options
enableParallelProcessing: bool = Field(default=True, description="Enable parallel processing of chunks")
- maxConcurrentChunks: int = Field(default=5, ge=1, le=20, description="Maximum number of chunks to process concurrently")
\ No newline at end of file
+ maxConcurrentChunks: int = Field(default=5, ge=1, le=20, description="Maximum number of chunks to process concurrently")
diff --git a/modules/datamodels/datamodelFeatures.py b/modules/datamodels/datamodelFeatures.py
index e43569b1..9a51f38b 100644
--- a/modules/datamodels/datamodelFeatures.py
+++ b/modules/datamodels/datamodelFeatures.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Feature models: Feature definitions, instances, data sources, and shared feature types."""
diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py
index 6adf6642..a78148f7 100644
--- a/modules/datamodels/datamodelFiles.py
+++ b/modules/datamodels/datamodelFiles.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""File-related datamodels: FileItem, FilePreview, FileData."""
diff --git a/modules/datamodels/datamodelInvitation.py b/modules/datamodels/datamodelInvitation.py
index 1543b42f..01e270a4 100644
--- a/modules/datamodels/datamodelInvitation.py
+++ b/modules/datamodels/datamodelInvitation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Invitation model for self-service onboarding.
diff --git a/modules/datamodels/datamodelJson.py b/modules/datamodels/datamodelJson.py
index 6c7793c4..5a975f73 100644
--- a/modules/datamodels/datamodelJson.py
+++ b/modules/datamodels/datamodelJson.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unified JSON document schema and helpers used by both generation prompts and renderers.
diff --git a/modules/datamodels/datamodelKnowledge.py b/modules/datamodels/datamodelKnowledge.py
index 725c0158..ad837ab1 100644
--- a/modules/datamodels/datamodelKnowledge.py
+++ b/modules/datamodels/datamodelKnowledge.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory.
diff --git a/modules/datamodels/datamodelMembership.py b/modules/datamodels/datamodelMembership.py
index 97f865d6..b59651a3 100644
--- a/modules/datamodels/datamodelMembership.py
+++ b/modules/datamodels/datamodelMembership.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Membership models: UserMandate, FeatureAccess, and Junction Tables.
diff --git a/modules/datamodels/datamodelMessaging.py b/modules/datamodels/datamodelMessaging.py
index 904ee526..5395ad9d 100644
--- a/modules/datamodels/datamodelMessaging.py
+++ b/modules/datamodels/datamodelMessaging.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Messaging models: MessagingSubscription, MessagingSubscriptionRegistration, MessagingDelivery."""
diff --git a/modules/datamodels/datamodelNavigation.py b/modules/datamodels/datamodelNavigation.py
index 22f851c8..5c40a165 100644
--- a/modules/datamodels/datamodelNavigation.py
+++ b/modules/datamodels/datamodelNavigation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Navigation structure data (Layer L1 - datamodels).
diff --git a/modules/datamodels/datamodelNotification.py b/modules/datamodels/datamodelNotification.py
index 535e6a65..2c8e8ede 100644
--- a/modules/datamodels/datamodelNotification.py
+++ b/modules/datamodels/datamodelNotification.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Notification model for in-app notifications.
diff --git a/modules/datamodels/datamodelPagination.py b/modules/datamodels/datamodelPagination.py
index 259f3880..2dba138d 100644
--- a/modules/datamodels/datamodelPagination.py
+++ b/modules/datamodels/datamodelPagination.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Pagination models for server-side pagination, sorting, and filtering.
diff --git a/modules/datamodels/datamodelPortTypes.py b/modules/datamodels/datamodelPortTypes.py
index 1357af4f..b118f0e6 100644
--- a/modules/datamodels/datamodelPortTypes.py
+++ b/modules/datamodels/datamodelPortTypes.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Port type catalog and primitive types for the Graphical Editor workflow system."""
diff --git a/modules/datamodels/datamodelRbac.py b/modules/datamodels/datamodelRbac.py
index 7ea9d710..56196d84 100644
--- a/modules/datamodels/datamodelRbac.py
+++ b/modules/datamodels/datamodelRbac.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
RBAC models: AccessRule, AccessRuleContext, Role.
diff --git a/modules/datamodels/datamodelSecurity.py b/modules/datamodels/datamodelSecurity.py
index 1240f088..280fdc9e 100644
--- a/modules/datamodels/datamodelSecurity.py
+++ b/modules/datamodels/datamodelSecurity.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Security models: Token and AuthEvent.
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index c8263e37..944f7606 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Subscription models: SubscriptionPlan (catalog), MandateSubscription (instance per mandate),
StripePlanPrice (persisted Stripe IDs per plan).
diff --git a/modules/datamodels/datamodelTickets.py b/modules/datamodels/datamodelTickets.py
index 149a7458..a7aaf8be 100644
--- a/modules/datamodels/datamodelTickets.py
+++ b/modules/datamodels/datamodelTickets.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Ticket datamodels used across Jira/ClickUp connectors."""
diff --git a/modules/datamodels/datamodelTools.py b/modules/datamodels/datamodelTools.py
index ed369748..3f67f136 100644
--- a/modules/datamodels/datamodelTools.py
+++ b/modules/datamodels/datamodelTools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Utility data models and classes for common tools and mappings.
diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py
index 1d95598c..18d1e9a5 100644
--- a/modules/datamodels/datamodelUam.py
+++ b/modules/datamodels/datamodelUam.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
UAM models: User, Mandate, UserConnection.
diff --git a/modules/datamodels/datamodelUdm.py b/modules/datamodels/datamodelUdm.py
index c91baa90..49bad763 100644
--- a/modules/datamodels/datamodelUdm.py
+++ b/modules/datamodels/datamodelUdm.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unified Document Model (UDM) — hierarchical document tree and ContentPart bridge."""
from __future__ import annotations
diff --git a/modules/datamodels/datamodelUiLanguage.py b/modules/datamodels/datamodelUiLanguage.py
index 4c589bb3..b6b04cd6 100644
--- a/modules/datamodels/datamodelUiLanguage.py
+++ b/modules/datamodels/datamodelUiLanguage.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""UI language sets: structured i18n entries (context, key, value)."""
diff --git a/modules/datamodels/datamodelUtils.py b/modules/datamodels/datamodelUtils.py
index 0bd0ed71..4713f691 100644
--- a/modules/datamodels/datamodelUtils.py
+++ b/modules/datamodels/datamodelUtils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Utility datamodels: Prompt, TextMultilingual."""
diff --git a/modules/datamodels/datamodelViews.py b/modules/datamodels/datamodelViews.py
index 28625d16..7e98406f 100644
--- a/modules/datamodels/datamodelViews.py
+++ b/modules/datamodels/datamodelViews.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
View models for the /api/attributes/ endpoint.
diff --git a/modules/datamodels/datamodelVoice.py b/modules/datamodels/datamodelVoice.py
index c3a622ac..76d9b7ae 100644
--- a/modules/datamodels/datamodelVoice.py
+++ b/modules/datamodels/datamodelVoice.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Voice settings datamodel — re-exported from UAM for central voice preferences."""
diff --git a/modules/datamodels/datamodelWorkflow.py b/modules/datamodels/datamodelWorkflow.py
index 490d9fb0..289f16c8 100644
--- a/modules/datamodels/datamodelWorkflow.py
+++ b/modules/datamodels/datamodelWorkflow.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Workflow execution models for action definitions, AI responses, and workflow-level structures.
diff --git a/modules/datamodels/datamodelWorkflowActions.py b/modules/datamodels/datamodelWorkflowActions.py
index e82941f6..ff044b82 100644
--- a/modules/datamodels/datamodelWorkflowActions.py
+++ b/modules/datamodels/datamodelWorkflowActions.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workflow Action models: WorkflowActionParameter, WorkflowActionDefinition."""
diff --git a/modules/datamodels/datamodelWorkflowAutomation.py b/modules/datamodels/datamodelWorkflowAutomation.py
index 51d84814..afaacd43 100644
--- a/modules/datamodels/datamodelWorkflowAutomation.py
+++ b/modules/datamodels/datamodelWorkflowAutomation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workflow Automation models: AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask.
diff --git a/modules/datamodels/serviceExceptions.py b/modules/datamodels/serviceExceptions.py
index 7585c6a9..587d1c72 100644
--- a/modules/datamodels/serviceExceptions.py
+++ b/modules/datamodels/serviceExceptions.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Shared service exception classes.
diff --git a/modules/dbHelpers/aiAuditLogger.py b/modules/dbHelpers/aiAuditLogger.py
index 060ace33..f351a733 100644
--- a/modules/dbHelpers/aiAuditLogger.py
+++ b/modules/dbHelpers/aiAuditLogger.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""AI Audit Logger — records every AI provider call for compliance reporting.
diff --git a/modules/dbHelpers/auditLogger.py b/modules/dbHelpers/auditLogger.py
index a5b0ec9e..3c799412 100644
--- a/modules/dbHelpers/auditLogger.py
+++ b/modules/dbHelpers/auditLogger.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Audit Logging System for PowerOn Gateway
diff --git a/modules/dbHelpers/dbMultiTenantOptimizations.py b/modules/dbHelpers/dbMultiTenantOptimizations.py
index 4b8a5e78..106b5c15 100644
--- a/modules/dbHelpers/dbMultiTenantOptimizations.py
+++ b/modules/dbHelpers/dbMultiTenantOptimizations.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Database optimizations for Multi-Tenant model.
diff --git a/modules/dbHelpers/dbRegistry.py b/modules/dbHelpers/dbRegistry.py
index 8c24d664..73384914 100644
--- a/modules/dbHelpers/dbRegistry.py
+++ b/modules/dbHelpers/dbRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Dynamic database registry — each interface self-registers its DB on import.
diff --git a/modules/dbHelpers/fkLabelResolver.py b/modules/dbHelpers/fkLabelResolver.py
index 940866d5..35a673af 100644
--- a/modules/dbHelpers/fkLabelResolver.py
+++ b/modules/dbHelpers/fkLabelResolver.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
FK label resolution: resolve foreign-key IDs to human-readable labels.
diff --git a/modules/dbHelpers/fkRegistry.py b/modules/dbHelpers/fkRegistry.py
index 9ca5b1ec..2e457594 100644
--- a/modules/dbHelpers/fkRegistry.py
+++ b/modules/dbHelpers/fkRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
FK-Discovery — scans the Model-Registry for `fk_target` annotations and
diff --git a/modules/dbHelpers/paginationHelpers.py b/modules/dbHelpers/paginationHelpers.py
index 981cd411..52235e76 100644
--- a/modules/dbHelpers/paginationHelpers.py
+++ b/modules/dbHelpers/paginationHelpers.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Pagination, filtering and sorting helpers for paginated record sets.
diff --git a/modules/features/commcoach/datamodelCommcoach.py b/modules/features/commcoach/datamodelCommcoach.py
index 06928998..2728a1de 100644
--- a/modules/features/commcoach/datamodelCommcoach.py
+++ b/modules/features/commcoach/datamodelCommcoach.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach Feature - Data Models.
diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py
index 8341ec1b..d4c51a27 100644
--- a/modules/features/commcoach/interfaceFeatureCommcoach.py
+++ b/modules/features/commcoach/interfaceFeatureCommcoach.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface to CommCoach database.
diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py
index 7050a078..ca07a9d0 100644
--- a/modules/features/commcoach/mainCommcoach.py
+++ b/modules/features/commcoach/mainCommcoach.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach Feature Container - Main Module.
diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py
index c7759900..81e1254d 100644
--- a/modules/features/commcoach/routeFeatureCommcoach.py
+++ b/modules/features/commcoach/routeFeatureCommcoach.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach routes for the backend API.
diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py
index 3aa0c1a0..b3b5ef2a 100644
--- a/modules/features/commcoach/serviceCommcoach.py
+++ b/modules/features/commcoach/serviceCommcoach.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach Service - Coaching Orchestration.
diff --git a/modules/features/commcoach/serviceCommcoachAi.py b/modules/features/commcoach/serviceCommcoachAi.py
index 1b9baca8..a924ec9e 100644
--- a/modules/features/commcoach/serviceCommcoachAi.py
+++ b/modules/features/commcoach/serviceCommcoachAi.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach AI Service.
diff --git a/modules/features/commcoach/serviceCommcoachContextRetrieval.py b/modules/features/commcoach/serviceCommcoachContextRetrieval.py
index 98673cc6..2f26cac7 100644
--- a/modules/features/commcoach/serviceCommcoachContextRetrieval.py
+++ b/modules/features/commcoach/serviceCommcoachContextRetrieval.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach Context Retrieval.
diff --git a/modules/features/commcoach/serviceCommcoachExport.py b/modules/features/commcoach/serviceCommcoachExport.py
index 5f8e9356..d3235e5e 100644
--- a/modules/features/commcoach/serviceCommcoachExport.py
+++ b/modules/features/commcoach/serviceCommcoachExport.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach Export Service.
diff --git a/modules/features/commcoach/serviceCommcoachGamification.py b/modules/features/commcoach/serviceCommcoachGamification.py
index 331dd9b1..3848c526 100644
--- a/modules/features/commcoach/serviceCommcoachGamification.py
+++ b/modules/features/commcoach/serviceCommcoachGamification.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach Gamification - Badge definitions and award logic.
diff --git a/modules/features/commcoach/serviceCommcoachIndexer.py b/modules/features/commcoach/serviceCommcoachIndexer.py
index 2f042795..8731d6a5 100644
--- a/modules/features/commcoach/serviceCommcoachIndexer.py
+++ b/modules/features/commcoach/serviceCommcoachIndexer.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach Session Indexer.
diff --git a/modules/features/commcoach/serviceCommcoachPersonas.py b/modules/features/commcoach/serviceCommcoachPersonas.py
index 867b51a0..fa99e6a2 100644
--- a/modules/features/commcoach/serviceCommcoachPersonas.py
+++ b/modules/features/commcoach/serviceCommcoachPersonas.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach Personas - Built-in roleplay persona definitions.
diff --git a/modules/features/commcoach/serviceCommcoachScheduler.py b/modules/features/commcoach/serviceCommcoachScheduler.py
index 51a3491d..916f481f 100644
--- a/modules/features/commcoach/serviceCommcoachScheduler.py
+++ b/modules/features/commcoach/serviceCommcoachScheduler.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CommCoach Scheduler Service.
diff --git a/modules/features/commcoach/tests/test_contextRetrieval.py b/modules/features/commcoach/tests/test_contextRetrieval.py
index a0dcf226..c646ca19 100644
--- a/modules/features/commcoach/tests/test_contextRetrieval.py
+++ b/modules/features/commcoach/tests/test_contextRetrieval.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Tests for CommCoach context retrieval (intent detection, session lookup)."""
diff --git a/modules/features/commcoach/tests/test_datamodel.py b/modules/features/commcoach/tests/test_datamodel.py
index 05d174c5..1191be21 100644
--- a/modules/features/commcoach/tests/test_datamodel.py
+++ b/modules/features/commcoach/tests/test_datamodel.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Tests for CommCoach data models.
diff --git a/modules/features/commcoach/tests/test_mainCommcoach.py b/modules/features/commcoach/tests/test_mainCommcoach.py
index bed151c8..bad60c5e 100644
--- a/modules/features/commcoach/tests/test_mainCommcoach.py
+++ b/modules/features/commcoach/tests/test_mainCommcoach.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Tests for CommCoach feature registration module.
diff --git a/modules/features/commcoach/tests/test_serviceAi.py b/modules/features/commcoach/tests/test_serviceAi.py
index bc8647b9..8fab02ad 100644
--- a/modules/features/commcoach/tests/test_serviceAi.py
+++ b/modules/features/commcoach/tests/test_serviceAi.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Tests for CommCoach AI service (prompt building and response parsing).
diff --git a/modules/features/neutralization/datamodelFeatureNeutralizer.py b/modules/features/neutralization/datamodelFeatureNeutralizer.py
index a308faa3..95b30a32 100644
--- a/modules/features/neutralization/datamodelFeatureNeutralizer.py
+++ b/modules/features/neutralization/datamodelFeatureNeutralizer.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Neutralizer models: DataNeutraliserConfig and DataNeutralizerAttributes."""
diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py
index 97a466ff..217b4029 100644
--- a/modules/features/neutralization/interfaceFeatureNeutralizer.py
+++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Database interface for the Neutralizer feature.
diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py
index 2a8a6e19..1991b09a 100644
--- a/modules/features/neutralization/mainNeutralization.py
+++ b/modules/features/neutralization/mainNeutralization.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Neutralizer Feature Container - Main Module.
diff --git a/modules/features/neutralization/neutralizePlayground.py b/modules/features/neutralization/neutralizePlayground.py
index 1a46cd25..407f5985 100644
--- a/modules/features/neutralization/neutralizePlayground.py
+++ b/modules/features/neutralization/neutralizePlayground.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import base64
import logging
diff --git a/modules/features/neutralization/routeFeatureNeutralizer.py b/modules/features/neutralization/routeFeatureNeutralizer.py
index 488ef352..f66372e9 100644
--- a/modules/features/neutralization/routeFeatureNeutralizer.py
+++ b/modules/features/neutralization/routeFeatureNeutralizer.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from fastapi import APIRouter, HTTPException, Depends, Path, Request, status, Query, Body, File, UploadFile
from typing import List, Dict, Any, Optional
diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
index 0388dbba..86d1965b 100644
--- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
+++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Data Neutralization Service
diff --git a/modules/features/neutralization/serviceNeutralization/subContentPartAdapter.py b/modules/features/neutralization/serviceNeutralization/subContentPartAdapter.py
index b7de66ca..09a7feb4 100644
--- a/modules/features/neutralization/serviceNeutralization/subContentPartAdapter.py
+++ b/modules/features/neutralization/serviceNeutralization/subContentPartAdapter.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Adapter to convert ContentPart list (from extraction) to renderer JSON schema.
diff --git a/modules/features/neutralization/serviceNeutralization/subParseString.py b/modules/features/neutralization/serviceNeutralization/subParseString.py
index 86ef2f16..54e52da3 100644
--- a/modules/features/neutralization/serviceNeutralization/subParseString.py
+++ b/modules/features/neutralization/serviceNeutralization/subParseString.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
String parsing and replacement utilities for data anonymization
diff --git a/modules/features/neutralization/serviceNeutralization/subPatterns.py b/modules/features/neutralization/serviceNeutralization/subPatterns.py
index f83c817e..642629c6 100644
--- a/modules/features/neutralization/serviceNeutralization/subPatterns.py
+++ b/modules/features/neutralization/serviceNeutralization/subPatterns.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Pattern definitions for data anonymization
@@ -470,4 +470,4 @@ def findPatternsInText(text: str, patterns: List[Pattern]) -> List[tuple]:
for p in pattern.patterns:
for match in re.finditer(p, text, re.IGNORECASE):
matches.append((pattern.name, match.group(0), match.start(), match.end()))
- return sorted(matches, key=lambda x: x[2]) # Sort by start position
\ No newline at end of file
+ return sorted(matches, key=lambda x: x[2]) # Sort by start position
diff --git a/modules/features/neutralization/serviceNeutralization/subProcessBinary.py b/modules/features/neutralization/serviceNeutralization/subProcessBinary.py
index 28bbf3ee..d1df1ed2 100644
--- a/modules/features/neutralization/serviceNeutralization/subProcessBinary.py
+++ b/modules/features/neutralization/serviceNeutralization/subProcessBinary.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Binary data processing module for data anonymization
diff --git a/modules/features/neutralization/serviceNeutralization/subProcessCommon.py b/modules/features/neutralization/serviceNeutralization/subProcessCommon.py
index dd49ae75..6c097e45 100644
--- a/modules/features/neutralization/serviceNeutralization/subProcessCommon.py
+++ b/modules/features/neutralization/serviceNeutralization/subProcessCommon.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Common processing utilities for data anonymization
diff --git a/modules/features/neutralization/serviceNeutralization/subProcessList.py b/modules/features/neutralization/serviceNeutralization/subProcessList.py
index 021cec2b..a42904ff 100644
--- a/modules/features/neutralization/serviceNeutralization/subProcessList.py
+++ b/modules/features/neutralization/serviceNeutralization/subProcessList.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
List processing module for data anonymization
diff --git a/modules/features/neutralization/serviceNeutralization/subProcessPdfInPlace.py b/modules/features/neutralization/serviceNeutralization/subProcessPdfInPlace.py
index b0c84327..695c8584 100644
--- a/modules/features/neutralization/serviceNeutralization/subProcessPdfInPlace.py
+++ b/modules/features/neutralization/serviceNeutralization/subProcessPdfInPlace.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
PDF in-place neutralization using PyMuPDF.
diff --git a/modules/features/neutralization/serviceNeutralization/subProcessText.py b/modules/features/neutralization/serviceNeutralization/subProcessText.py
index eea270b9..bdc4c995 100644
--- a/modules/features/neutralization/serviceNeutralization/subProcessText.py
+++ b/modules/features/neutralization/serviceNeutralization/subProcessText.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Text processing module for data anonymization
diff --git a/modules/features/redmine/__init__.py b/modules/features/redmine/__init__.py
index 964637d5..ba98788e 100644
--- a/modules/features/redmine/__init__.py
+++ b/modules/features/redmine/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Redmine feature container -- ticket browser, statistics, AI tools."""
diff --git a/modules/features/redmine/datamodelRedmine.py b/modules/features/redmine/datamodelRedmine.py
index e33ee407..eb5c7f27 100644
--- a/modules/features/redmine/datamodelRedmine.py
+++ b/modules/features/redmine/datamodelRedmine.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Redmine feature data models.
diff --git a/modules/features/redmine/interfaceFeatureRedmine.py b/modules/features/redmine/interfaceFeatureRedmine.py
index 225b5a31..fa885151 100644
--- a/modules/features/redmine/interfaceFeatureRedmine.py
+++ b/modules/features/redmine/interfaceFeatureRedmine.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Interface for the Redmine feature.
diff --git a/modules/features/redmine/mainRedmine.py b/modules/features/redmine/mainRedmine.py
index fe893cef..2d3d8d5f 100644
--- a/modules/features/redmine/mainRedmine.py
+++ b/modules/features/redmine/mainRedmine.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Redmine Feature Container -- Main Module.
diff --git a/modules/features/redmine/routeFeatureRedmine.py b/modules/features/redmine/routeFeatureRedmine.py
index bdc8797b..86ac8d30 100644
--- a/modules/features/redmine/routeFeatureRedmine.py
+++ b/modules/features/redmine/routeFeatureRedmine.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""FastAPI routes for the Redmine feature.
diff --git a/modules/features/redmine/serviceRedmine.py b/modules/features/redmine/serviceRedmine.py
index d772478b..0fe2f2b3 100644
--- a/modules/features/redmine/serviceRedmine.py
+++ b/modules/features/redmine/serviceRedmine.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Redmine service layer.
diff --git a/modules/features/redmine/serviceRedmineStats.py b/modules/features/redmine/serviceRedmineStats.py
index 8566db16..dc187d85 100644
--- a/modules/features/redmine/serviceRedmineStats.py
+++ b/modules/features/redmine/serviceRedmineStats.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Redmine statistics aggregator.
diff --git a/modules/features/redmine/serviceRedmineStatsCache.py b/modules/features/redmine/serviceRedmineStatsCache.py
index 12176178..ae9f9718 100644
--- a/modules/features/redmine/serviceRedmineStatsCache.py
+++ b/modules/features/redmine/serviceRedmineStatsCache.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""TTL-based in-memory cache for ``serviceRedmineStats`` results.
diff --git a/modules/features/redmine/serviceRedmineSync.py b/modules/features/redmine/serviceRedmineSync.py
index a56198f1..38cdd4f9 100644
--- a/modules/features/redmine/serviceRedmineSync.py
+++ b/modules/features/redmine/serviceRedmineSync.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Incremental Redmine -> ``poweron_redmine`` mirror sync.
diff --git a/modules/features/redmine/workflows/__init__.py b/modules/features/redmine/workflows/__init__.py
index 8c4ceb1a..0ec13b90 100644
--- a/modules/features/redmine/workflows/__init__.py
+++ b/modules/features/redmine/workflows/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Feature-owned workflow methods for Redmine."""
diff --git a/modules/features/redmine/workflows/methodRedmine/__init__.py b/modules/features/redmine/workflows/methodRedmine/__init__.py
index d141dd48..2c8e1590 100644
--- a/modules/features/redmine/workflows/methodRedmine/__init__.py
+++ b/modules/features/redmine/workflows/methodRedmine/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Redmine workflow method: read / list / create / update / stats / sync."""
diff --git a/modules/features/redmine/workflows/methodRedmine/actions/__init__.py b/modules/features/redmine/workflows/methodRedmine/actions/__init__.py
index 746291ab..06003961 100644
--- a/modules/features/redmine/workflows/methodRedmine/actions/__init__.py
+++ b/modules/features/redmine/workflows/methodRedmine/actions/__init__.py
@@ -1,2 +1,2 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
diff --git a/modules/features/redmine/workflows/methodRedmine/actions/_shared.py b/modules/features/redmine/workflows/methodRedmine/actions/_shared.py
index b7c585d3..0b7709dc 100644
--- a/modules/features/redmine/workflows/methodRedmine/actions/_shared.py
+++ b/modules/features/redmine/workflows/methodRedmine/actions/_shared.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Shared helpers for Redmine workflow actions.
diff --git a/modules/features/redmine/workflows/methodRedmine/actions/createTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/createTicket.py
index 499d21fb..d7924f2f 100644
--- a/modules/features/redmine/workflows/methodRedmine/actions/createTicket.py
+++ b/modules/features/redmine/workflows/methodRedmine/actions/createTicket.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workflow action: create a new Redmine ticket."""
diff --git a/modules/features/redmine/workflows/methodRedmine/actions/getStats.py b/modules/features/redmine/workflows/methodRedmine/actions/getStats.py
index e939bf53..3da3fdd9 100644
--- a/modules/features/redmine/workflows/methodRedmine/actions/getStats.py
+++ b/modules/features/redmine/workflows/methodRedmine/actions/getStats.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workflow action: fetch aggregated Redmine statistics from the mirror."""
diff --git a/modules/features/redmine/workflows/methodRedmine/actions/listRelations.py b/modules/features/redmine/workflows/methodRedmine/actions/listRelations.py
index 90f44594..b8d45cfa 100644
--- a/modules/features/redmine/workflows/methodRedmine/actions/listRelations.py
+++ b/modules/features/redmine/workflows/methodRedmine/actions/listRelations.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workflow action: list Redmine relations from the mirror."""
diff --git a/modules/features/redmine/workflows/methodRedmine/actions/listTickets.py b/modules/features/redmine/workflows/methodRedmine/actions/listTickets.py
index 8573237a..17c6094c 100644
--- a/modules/features/redmine/workflows/methodRedmine/actions/listTickets.py
+++ b/modules/features/redmine/workflows/methodRedmine/actions/listTickets.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workflow action: list Redmine tickets from the mirror with filters."""
diff --git a/modules/features/redmine/workflows/methodRedmine/actions/readTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/readTicket.py
index 69ea4459..afd3f5be 100644
--- a/modules/features/redmine/workflows/methodRedmine/actions/readTicket.py
+++ b/modules/features/redmine/workflows/methodRedmine/actions/readTicket.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workflow action: read a single Redmine ticket from the mirror.
diff --git a/modules/features/redmine/workflows/methodRedmine/actions/runSync.py b/modules/features/redmine/workflows/methodRedmine/actions/runSync.py
index 64a9bff9..f4a65d53 100644
--- a/modules/features/redmine/workflows/methodRedmine/actions/runSync.py
+++ b/modules/features/redmine/workflows/methodRedmine/actions/runSync.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workflow action: trigger an incremental (or full) Redmine mirror sync."""
diff --git a/modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py
index 4e396093..eacb5a38 100644
--- a/modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py
+++ b/modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workflow action: update a single Redmine ticket and refresh the mirror."""
diff --git a/modules/features/redmine/workflows/methodRedmine/methodRedmine.py b/modules/features/redmine/workflows/methodRedmine/methodRedmine.py
index 700375cd..1b26308e 100644
--- a/modules/features/redmine/workflows/methodRedmine/methodRedmine.py
+++ b/modules/features/redmine/workflows/methodRedmine/methodRedmine.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Redmine workflow method.
diff --git a/modules/features/teamsbot/__init__.py b/modules/features/teamsbot/__init__.py
index fdcc4f0e..06003961 100644
--- a/modules/features/teamsbot/__init__.py
+++ b/modules/features/teamsbot/__init__.py
@@ -1,2 +1,2 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
diff --git a/modules/features/teamsbot/bridgeConnector.py b/modules/features/teamsbot/bridgeConnector.py
index f97f4103..35cd4401 100644
--- a/modules/features/teamsbot/bridgeConnector.py
+++ b/modules/features/teamsbot/bridgeConnector.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Bridge Connector - Communication with the .NET Media Bridge.
diff --git a/modules/features/teamsbot/browserBotConnector.py b/modules/features/teamsbot/browserBotConnector.py
index d99fe829..77df0300 100644
--- a/modules/features/teamsbot/browserBotConnector.py
+++ b/modules/features/teamsbot/browserBotConnector.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Browser Bot Connector - Communication with the Node.js Browser Bot Service.
diff --git a/modules/features/teamsbot/config.py b/modules/features/teamsbot/config.py
index 11a9e3ff..e8a7adc2 100644
--- a/modules/features/teamsbot/config.py
+++ b/modules/features/teamsbot/config.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Teamsbot Feature - Configuration utilities.
diff --git a/modules/features/teamsbot/datamodelTeamsbot.py b/modules/features/teamsbot/datamodelTeamsbot.py
index 70ba5fd5..4bb41ccf 100644
--- a/modules/features/teamsbot/datamodelTeamsbot.py
+++ b/modules/features/teamsbot/datamodelTeamsbot.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Teamsbot Feature - Data Models.
diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py
index 5afeea69..9eca9492 100644
--- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py
+++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface to Teamsbot database.
diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py
index 5a003182..afb04004 100644
--- a/modules/features/teamsbot/mainTeamsbot.py
+++ b/modules/features/teamsbot/mainTeamsbot.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Teamsbot Feature Container - Main Module.
diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py
index c0862ba1..b2ac2980 100644
--- a/modules/features/teamsbot/routeFeatureTeamsbot.py
+++ b/modules/features/teamsbot/routeFeatureTeamsbot.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Teamsbot routes for the backend API.
diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py
index 2487ad81..942096a1 100644
--- a/modules/features/teamsbot/service.py
+++ b/modules/features/teamsbot/service.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Teamsbot Service - Pipeline Orchestrator.
diff --git a/modules/features/teamsbot/serviceCommands.py b/modules/features/teamsbot/serviceCommands.py
index a8ce763f..5e08f879 100644
--- a/modules/features/teamsbot/serviceCommands.py
+++ b/modules/features/teamsbot/serviceCommands.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Teamsbot Service — AI command execution logic.
diff --git a/modules/features/teamsbot/serviceConversation.py b/modules/features/teamsbot/serviceConversation.py
index bf844d89..43835aa6 100644
--- a/modules/features/teamsbot/serviceConversation.py
+++ b/modules/features/teamsbot/serviceConversation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Teamsbot Service — Conversation & AI analysis logic.
diff --git a/modules/features/teamsbot/serviceWebSocket.py b/modules/features/teamsbot/serviceWebSocket.py
index 2c462624..bea744dc 100644
--- a/modules/features/teamsbot/serviceWebSocket.py
+++ b/modules/features/teamsbot/serviceWebSocket.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Teamsbot Service — WebSocket handler & audio chunk processing.
diff --git a/modules/features/trustee/accounting/__init__.py b/modules/features/trustee/accounting/__init__.py
index fdcc4f0e..06003961 100644
--- a/modules/features/trustee/accounting/__init__.py
+++ b/modules/features/trustee/accounting/__init__.py
@@ -1,2 +1,2 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
diff --git a/modules/features/trustee/accounting/accountingBridge.py b/modules/features/trustee/accounting/accountingBridge.py
index 7fb26b3a..51433bbf 100644
--- a/modules/features/trustee/accounting/accountingBridge.py
+++ b/modules/features/trustee/accounting/accountingBridge.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Accounting bridge: standardised interface between Trustee and external accounting systems.
diff --git a/modules/features/trustee/accounting/accountingConnectorBase.py b/modules/features/trustee/accounting/accountingConnectorBase.py
index 6a59509f..5a569b75 100644
--- a/modules/features/trustee/accounting/accountingConnectorBase.py
+++ b/modules/features/trustee/accounting/accountingConnectorBase.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Abstract base class and standard data models for accounting system connectors."""
diff --git a/modules/features/trustee/accounting/accountingDataSync.py b/modules/features/trustee/accounting/accountingDataSync.py
index 8ee3b431..82996492 100644
--- a/modules/features/trustee/accounting/accountingDataSync.py
+++ b/modules/features/trustee/accounting/accountingDataSync.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Orchestrates importing accounting data from external systems into TrusteeData* tables.
diff --git a/modules/features/trustee/accounting/accountingRegistry.py b/modules/features/trustee/accounting/accountingRegistry.py
index fe1b20d5..c17933da 100644
--- a/modules/features/trustee/accounting/accountingRegistry.py
+++ b/modules/features/trustee/accounting/accountingRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Plugin-discovery registry for accounting connectors (analogous to aicoreModelRegistry)."""
diff --git a/modules/features/trustee/accounting/connectors/__init__.py b/modules/features/trustee/accounting/connectors/__init__.py
index fdcc4f0e..06003961 100644
--- a/modules/features/trustee/accounting/connectors/__init__.py
+++ b/modules/features/trustee/accounting/connectors/__init__.py
@@ -1,2 +1,2 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
index a1947b27..04427234 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Abacus ERP accounting connector.
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
index 28c2a334..4021d23e 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Bexio accounting connector.
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
index 98634127..034fb2d3 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Run My Accounts (Infoniqa) accounting connector.
diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py
index ad85105e..65d7a1b5 100644
--- a/modules/features/trustee/datamodelFeatureTrustee.py
+++ b/modules/features/trustee/datamodelFeatureTrustee.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Trustee models: TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, TrusteeDocument, TrusteePosition."""
diff --git a/modules/features/trustee/handlerTrusteeAccounting.py b/modules/features/trustee/handlerTrusteeAccounting.py
index 212d20e3..0e0103fa 100644
--- a/modules/features/trustee/handlerTrusteeAccounting.py
+++ b/modules/features/trustee/handlerTrusteeAccounting.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Business logic for Trustee accounting integration endpoints.
diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py
index 4efaaaef..1e13f185 100644
--- a/modules/features/trustee/interfaceFeatureTrustee.py
+++ b/modules/features/trustee/interfaceFeatureTrustee.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface to Trustee database.
diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py
index 4bcee319..33112a12 100644
--- a/modules/features/trustee/mainTrustee.py
+++ b/modules/features/trustee/mainTrustee.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Trustee Feature Container - Main Module.
diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py
index 8b2452af..c06f8604 100644
--- a/modules/features/trustee/routeFeatureTrustee.py
+++ b/modules/features/trustee/routeFeatureTrustee.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Routes for Trustee feature data management.
diff --git a/modules/features/trustee/trusteeOntology.py b/modules/features/trustee/trusteeOntology.py
index c5b117d7..62260439 100644
--- a/modules/features/trustee/trusteeOntology.py
+++ b/modules/features/trustee/trusteeOntology.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Trustee feature ontology (Phase 2 pilot).
diff --git a/modules/features/trustee/workflows/__init__.py b/modules/features/trustee/workflows/__init__.py
index 976edabd..7c0edb08 100644
--- a/modules/features/trustee/workflows/__init__.py
+++ b/modules/features/trustee/workflows/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Trustee feature-owned workflow methods."""
diff --git a/modules/features/trustee/workflows/methodTrustee/__init__.py b/modules/features/trustee/workflows/methodTrustee/__init__.py
index fa7acc95..e3590d46 100644
--- a/modules/features/trustee/workflows/methodTrustee/__init__.py
+++ b/modules/features/trustee/workflows/methodTrustee/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Trustee document and expense workflow method (extract, process, sync to accounting)."""
diff --git a/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py b/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py
index d28c8a3c..240809c1 100644
--- a/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py
+++ b/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Extract document type and structured data from files (PDF, JPG).
diff --git a/modules/features/trustee/workflows/methodTrustee/actions/processDocuments.py b/modules/features/trustee/workflows/methodTrustee/actions/processDocuments.py
index ab738a14..2b07ad86 100644
--- a/modules/features/trustee/workflows/methodTrustee/actions/processDocuments.py
+++ b/modules/features/trustee/workflows/methodTrustee/actions/processDocuments.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Process extracted documents: create TrusteeDocument + TrusteePosition from extraction JSON.
diff --git a/modules/features/trustee/workflows/methodTrustee/actions/queryData.py b/modules/features/trustee/workflows/methodTrustee/actions/queryData.py
index b30c9390..82f77a0e 100644
--- a/modules/features/trustee/workflows/methodTrustee/actions/queryData.py
+++ b/modules/features/trustee/workflows/methodTrustee/actions/queryData.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Query data from the Trustee feature DB.
diff --git a/modules/features/trustee/workflows/methodTrustee/actions/refreshAccountingData.py b/modules/features/trustee/workflows/methodTrustee/actions/refreshAccountingData.py
index 817d229a..a3f8eed3 100644
--- a/modules/features/trustee/workflows/methodTrustee/actions/refreshAccountingData.py
+++ b/modules/features/trustee/workflows/methodTrustee/actions/refreshAccountingData.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Refresh accounting data from external system (e.g. Abacus) into local TrusteeData* tables.
diff --git a/modules/features/trustee/workflows/methodTrustee/actions/syncToAccounting.py b/modules/features/trustee/workflows/methodTrustee/actions/syncToAccounting.py
index 9529e699..1cd4c588 100644
--- a/modules/features/trustee/workflows/methodTrustee/actions/syncToAccounting.py
+++ b/modules/features/trustee/workflows/methodTrustee/actions/syncToAccounting.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Sync trustee positions to accounting (Buha).
diff --git a/modules/features/trustee/workflows/methodTrustee/methodTrustee.py b/modules/features/trustee/workflows/methodTrustee/methodTrustee.py
index 73e7d573..65df051a 100644
--- a/modules/features/trustee/workflows/methodTrustee/methodTrustee.py
+++ b/modules/features/trustee/workflows/methodTrustee/methodTrustee.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Trustee document workflow method: extract from files, process to positions, sync to accounting.
diff --git a/modules/features/workspace/__init__.py b/modules/features/workspace/__init__.py
index 2e48ea1c..bb9a0a2d 100644
--- a/modules/features/workspace/__init__.py
+++ b/modules/features/workspace/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unified AI Workspace feature."""
diff --git a/modules/features/workspace/datamodelFeatureWorkspace.py b/modules/features/workspace/datamodelFeatureWorkspace.py
index d0ba8815..1e467849 100644
--- a/modules/features/workspace/datamodelFeatureWorkspace.py
+++ b/modules/features/workspace/datamodelFeatureWorkspace.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workspace feature data models — WorkspaceUserSettings."""
diff --git a/modules/features/workspace/interfaceFeatureWorkspace.py b/modules/features/workspace/interfaceFeatureWorkspace.py
index e2d16521..09fca043 100644
--- a/modules/features/workspace/interfaceFeatureWorkspace.py
+++ b/modules/features/workspace/interfaceFeatureWorkspace.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface for Workspace feature — manages WorkspaceUserSettings.
diff --git a/modules/features/workspace/mainWorkspace.py b/modules/features/workspace/mainWorkspace.py
index 1a96a852..605ab9c3 100644
--- a/modules/features/workspace/mainWorkspace.py
+++ b/modules/features/workspace/mainWorkspace.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Workspace Feature Container - Main Module.
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index fedda841..66ed4966 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unified AI Workspace routes.
diff --git a/modules/interfaces/_legacyMigrationTelemetry.py b/modules/interfaces/_legacyMigrationTelemetry.py
index d80905b1..12fb1ae8 100644
--- a/modules/interfaces/_legacyMigrationTelemetry.py
+++ b/modules/interfaces/_legacyMigrationTelemetry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Lightweight Bootstrap-Telemetrie fuer entfernte Migrationsroutinen.
diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py
index 13f5d8a7..c36e10b6 100644
--- a/modules/interfaces/interfaceAiObjects.py
+++ b/modules/interfaces/interfaceAiObjects.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import asyncio
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 19ff4e26..6a8522d7 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Centralized bootstrap interface for system initialization.
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 13c7ead6..52cd5a59 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface to the Gateway system.
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index 84cc748e..94600a0c 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface for Billing operations.
diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py
index 39d95440..71ccb774 100644
--- a/modules/interfaces/interfaceDbChat.py
+++ b/modules/interfaces/interfaceDbChat.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface to LucyDOM database and AI Connectors.
diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py
index 20d66dc2..c52d999e 100644
--- a/modules/interfaces/interfaceDbKnowledge.py
+++ b/modules/interfaces/interfaceDbKnowledge.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface to the Knowledge Store database (poweron_knowledge).
diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py
index b3acfcfc..93e2d1c3 100644
--- a/modules/interfaces/interfaceDbManagement.py
+++ b/modules/interfaces/interfaceDbManagement.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface to Management database and AI Connectors.
@@ -2275,4 +2275,4 @@ def buildResolverDbInterface(chatService):
appIf = getattr(chatService, "interfaceDbApp", None)
if appIf:
return _ResolverDbAdapter(appIf)
- return getattr(chatService, "interfaceDbComponent", None)
\ No newline at end of file
+ return getattr(chatService, "interfaceDbComponent", None)
diff --git a/modules/interfaces/interfaceDbSubscription.py b/modules/interfaces/interfaceDbSubscription.py
index b6cb26ff..04288d23 100644
--- a/modules/interfaces/interfaceDbSubscription.py
+++ b/modules/interfaces/interfaceDbSubscription.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface for Subscription operations — ID-based, deterministic.
diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py
index c391deaa..885d5bcb 100644
--- a/modules/interfaces/interfaceFeatures.py
+++ b/modules/interfaces/interfaceFeatures.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Feature Instance Management Interface.
diff --git a/modules/interfaces/interfaceMessaging.py b/modules/interfaces/interfaceMessaging.py
index 6a0eb54c..298edf6c 100644
--- a/modules/interfaces/interfaceMessaging.py
+++ b/modules/interfaces/interfaceMessaging.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface for Messaging Services
diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py
index 5c09942a..ebcf8c56 100644
--- a/modules/interfaces/interfaceRbac.py
+++ b/modules/interfaces/interfaceRbac.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
RBAC helper functions for interfaces.
diff --git a/modules/interfaces/interfaceTableHelpers.py b/modules/interfaces/interfaceTableHelpers.py
index 81336fed..e7c188c5 100644
--- a/modules/interfaces/interfaceTableHelpers.py
+++ b/modules/interfaces/interfaceTableHelpers.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Table/list presentation helpers: view resolution, grouping, Strategy B.
diff --git a/modules/interfaces/interfaceTicketObjects.py b/modules/interfaces/interfaceTicketObjects.py
index 6525eae5..b2ef7bf9 100644
--- a/modules/interfaces/interfaceTicketObjects.py
+++ b/modules/interfaces/interfaceTicketObjects.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Optional
from datetime import datetime, timezone
diff --git a/modules/interfaces/interfaceVoiceObjects.py b/modules/interfaces/interfaceVoiceObjects.py
index 03729f86..636dbbf6 100644
--- a/modules/interfaces/interfaceVoiceObjects.py
+++ b/modules/interfaces/interfaceVoiceObjects.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface for Voice Services
diff --git a/modules/interfaces/interfaceWorkflowAutomation.py b/modules/interfaces/interfaceWorkflowAutomation.py
index 9859ff2d..84efe40a 100644
--- a/modules/interfaces/interfaceWorkflowAutomation.py
+++ b/modules/interfaces/interfaceWorkflowAutomation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Interface for WorkflowAutomation system component - Workflows, Runs, Human Tasks.
diff --git a/modules/nodeCatalog/__init__.py b/modules/nodeCatalog/__init__.py
index cbe6a49e..6c09ea27 100644
--- a/modules/nodeCatalog/__init__.py
+++ b/modules/nodeCatalog/__init__.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
nodeCatalog (L2) — neutraler Node-Definitions-Container.
diff --git a/modules/nodeCatalog/_workflowFileSchema.py b/modules/nodeCatalog/_workflowFileSchema.py
index efb06aea..3d04318d 100644
--- a/modules/nodeCatalog/_workflowFileSchema.py
+++ b/modules/nodeCatalog/_workflowFileSchema.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Workflow File Schema (Versioned Envelope) for WorkflowAutomation.
diff --git a/modules/nodeCatalog/entryPoints.py b/modules/nodeCatalog/entryPoints.py
index b1a8ae03..3f11f184 100644
--- a/modules/nodeCatalog/entryPoints.py
+++ b/modules/nodeCatalog/entryPoints.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Workflow entry points (Starts) — configuration outside the flow editor.
diff --git a/modules/nodeCatalog/nodeAdapter.py b/modules/nodeCatalog/nodeAdapter.py
index f0cd1469..ff974073 100644
--- a/modules/nodeCatalog/nodeAdapter.py
+++ b/modules/nodeCatalog/nodeAdapter.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Schicht-3 Adapter Layer — projects Schicht-2 Actions into Editor-Node form.
diff --git a/modules/nodeCatalog/nodeDefinitions/__init__.py b/modules/nodeCatalog/nodeDefinitions/__init__.py
index 31895a44..17fdd424 100644
--- a/modules/nodeCatalog/nodeDefinitions/__init__.py
+++ b/modules/nodeCatalog/nodeDefinitions/__init__.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Node type definitions for automation2 flow builder.
from .triggers import TRIGGER_NODES
diff --git a/modules/nodeCatalog/nodeDefinitions/ai.py b/modules/nodeCatalog/nodeDefinitions/ai.py
index 8e0f081e..ab2f9893 100644
--- a/modules/nodeCatalog/nodeDefinitions/ai.py
+++ b/modules/nodeCatalog/nodeDefinitions/ai.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# AI node definitions - map to methodAi actions.
from modules.shared.i18nRegistry import t
diff --git a/modules/nodeCatalog/nodeDefinitions/clickup.py b/modules/nodeCatalog/nodeDefinitions/clickup.py
index 1e330d29..fde1cec1 100644
--- a/modules/nodeCatalog/nodeDefinitions/clickup.py
+++ b/modules/nodeCatalog/nodeDefinitions/clickup.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp nodes — map to MethodClickup actions."""
diff --git a/modules/nodeCatalog/nodeDefinitions/context.py b/modules/nodeCatalog/nodeDefinitions/context.py
index dc05fa40..139fed83 100644
--- a/modules/nodeCatalog/nodeDefinitions/context.py
+++ b/modules/nodeCatalog/nodeDefinitions/context.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Context node definitions — structural extraction without AI plus
# generic key/value, merge, filter and transform helpers.
diff --git a/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py b/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py
index 116164c1..740ecb35 100644
--- a/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py
+++ b/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Shared parameter copy for ``contextBuilder`` fields (upstream data pick).
from modules.shared.i18nRegistry import t
diff --git a/modules/nodeCatalog/nodeDefinitions/data.py b/modules/nodeCatalog/nodeDefinitions/data.py
index a12ddeb6..c6b5cdab 100644
--- a/modules/nodeCatalog/nodeDefinitions/data.py
+++ b/modules/nodeCatalog/nodeDefinitions/data.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Data manipulation node definitions: aggregate, transform, filter.
from modules.shared.i18nRegistry import t
diff --git a/modules/nodeCatalog/nodeDefinitions/email.py b/modules/nodeCatalog/nodeDefinitions/email.py
index a0503452..c2dfc0e5 100644
--- a/modules/nodeCatalog/nodeDefinitions/email.py
+++ b/modules/nodeCatalog/nodeDefinitions/email.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Email node definitions - map to methodOutlook actions.
from modules.shared.i18nRegistry import t
diff --git a/modules/nodeCatalog/nodeDefinitions/file.py b/modules/nodeCatalog/nodeDefinitions/file.py
index 70c13a07..8030d8cb 100644
--- a/modules/nodeCatalog/nodeDefinitions/file.py
+++ b/modules/nodeCatalog/nodeDefinitions/file.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# File node definitions - create files from context (e.g. from AI nodes).
from modules.shared.i18nRegistry import t
diff --git a/modules/nodeCatalog/nodeDefinitions/flow.py b/modules/nodeCatalog/nodeDefinitions/flow.py
index 94f517d9..b9451819 100644
--- a/modules/nodeCatalog/nodeDefinitions/flow.py
+++ b/modules/nodeCatalog/nodeDefinitions/flow.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Flow control node definitions.
from modules.shared.i18nRegistry import t
diff --git a/modules/nodeCatalog/nodeDefinitions/input.py b/modules/nodeCatalog/nodeDefinitions/input.py
index 0f469880..b68fa74d 100644
--- a/modules/nodeCatalog/nodeDefinitions/input.py
+++ b/modules/nodeCatalog/nodeDefinitions/input.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Input/Human node definitions - nodes that require user action.
from modules.shared.i18nRegistry import t
diff --git a/modules/nodeCatalog/nodeDefinitions/redmine.py b/modules/nodeCatalog/nodeDefinitions/redmine.py
index bf61cd26..730bcffa 100644
--- a/modules/nodeCatalog/nodeDefinitions/redmine.py
+++ b/modules/nodeCatalog/nodeDefinitions/redmine.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Redmine node definitions - map to MethodRedmine actions."""
diff --git a/modules/nodeCatalog/nodeDefinitions/sharepoint.py b/modules/nodeCatalog/nodeDefinitions/sharepoint.py
index ae56f9a6..6e011e93 100644
--- a/modules/nodeCatalog/nodeDefinitions/sharepoint.py
+++ b/modules/nodeCatalog/nodeDefinitions/sharepoint.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# SharePoint node definitions - map to methodSharepoint actions.
from modules.shared.i18nRegistry import t
diff --git a/modules/nodeCatalog/nodeDefinitions/triggers.py b/modules/nodeCatalog/nodeDefinitions/triggers.py
index deeae7a0..3343b284 100644
--- a/modules/nodeCatalog/nodeDefinitions/triggers.py
+++ b/modules/nodeCatalog/nodeDefinitions/triggers.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Start nodes (palette category ``start``); kinds align with workflow entry points / run envelope.
from modules.shared.i18nRegistry import t
diff --git a/modules/nodeCatalog/nodeDefinitions/trustee.py b/modules/nodeCatalog/nodeDefinitions/trustee.py
index b0521696..840b6ff5 100644
--- a/modules/nodeCatalog/nodeDefinitions/trustee.py
+++ b/modules/nodeCatalog/nodeDefinitions/trustee.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Trustee node definitions - map to methodTrustee actions.
from modules.shared.i18nRegistry import t
diff --git a/modules/nodeCatalog/portTypes.py b/modules/nodeCatalog/portTypes.py
index aa8f4385..f3995099 100644
--- a/modules/nodeCatalog/portTypes.py
+++ b/modules/nodeCatalog/portTypes.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Typed Port System for the Graphical Editor.
diff --git a/modules/routes/routeAdmin.py b/modules/routes/routeAdmin.py
index 0f671f0a..5f9c2b02 100644
--- a/modules/routes/routeAdmin.py
+++ b/modules/routes/routeAdmin.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from fastapi import APIRouter, Response, Depends, Request, Body
from fastapi.responses import FileResponse
diff --git a/modules/routes/routeAdminDatabaseHealth.py b/modules/routes/routeAdminDatabaseHealth.py
index f2f866d9..545c5b1b 100644
--- a/modules/routes/routeAdminDatabaseHealth.py
+++ b/modules/routes/routeAdminDatabaseHealth.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
SysAdmin API for database table statistics, FK orphan detection/cleanup,
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index 350d8311..0a9626dc 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Feature management routes for the backend API.
diff --git a/modules/routes/routeAdminLogs.py b/modules/routes/routeAdminLogs.py
index 926c7370..e5a1357c 100644
--- a/modules/routes/routeAdminLogs.py
+++ b/modules/routes/routeAdminLogs.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Admin log viewer routes for the backend API.
diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py
index 3b09d7eb..36577de7 100644
--- a/modules/routes/routeAdminRbacRules.py
+++ b/modules/routes/routeAdminRbacRules.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
RBAC routes for the backend API.
diff --git a/modules/routes/routeAdminUserAccessOverview.py b/modules/routes/routeAdminUserAccessOverview.py
index 4906c093..ed66cb41 100644
--- a/modules/routes/routeAdminUserAccessOverview.py
+++ b/modules/routes/routeAdminUserAccessOverview.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Admin User Access Overview routes.
diff --git a/modules/routes/routeAttributes.py b/modules/routes/routeAttributes.py
index ae0cd6f4..eeec151e 100644
--- a/modules/routes/routeAttributes.py
+++ b/modules/routes/routeAttributes.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from fastapi import APIRouter, HTTPException, Path, Response, Request
from fastapi import status
@@ -85,4 +85,4 @@ def options_entity_attributes(
entityType: str = Path(..., description="Type of entity (e.g. prompt)")
) -> Response:
"""Handle OPTIONS request for CORS preflight"""
- return Response(status_code=200)
\ No newline at end of file
+ return Response(status_code=200)
diff --git a/modules/routes/routeAudit.py b/modules/routes/routeAudit.py
index c9888339..9dfd074d 100644
--- a/modules/routes/routeAudit.py
+++ b/modules/routes/routeAudit.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Compliance & Audit API endpoints.
diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py
index 143af2e2..ca95f7da 100644
--- a/modules/routes/routeBilling.py
+++ b/modules/routes/routeBilling.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Billing routes for the backend API.
diff --git a/modules/routes/routeClickup.py b/modules/routes/routeClickup.py
index 41797d77..635f1352 100644
--- a/modules/routes/routeClickup.py
+++ b/modules/routes/routeClickup.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp API routes — lists and tasks (connection-scoped). OAuth lives under /api/clickup/auth/* in routeSecurityClickup."""
diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py
index 7a327d16..2d425d25 100644
--- a/modules/routes/routeDataConnections.py
+++ b/modules/routes/routeDataConnections.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Connection routes for the backend API.
@@ -906,4 +906,4 @@ def _stopKnowledgeJobs(
raise
except Exception as e:
logger.error("Error stopping knowledge jobs: %s", e, exc_info=True)
- raise HTTPException(status_code=500, detail=str(e))
\ No newline at end of file
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py
index a7a4e34b..63189e53 100644
--- a/modules/routes/routeDataFiles.py
+++ b/modules/routes/routeDataFiles.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Form, Path, Request, status, Query, Response, Body, BackgroundTasks
from fastapi.responses import JSONResponse
@@ -774,7 +774,7 @@ def get_files(
allItems = enrichRowsWithFkLabels(
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
FileItem,
- db=managementInterface.db,
+ db=appInterface.db,
)
filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels)
@@ -786,7 +786,7 @@ def get_files(
allFiles = managementInterface.getAllFiles()
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
itemDicts = _filesToDicts(items)
- enrichRowsWithFkLabels(itemDicts, FileItem)
+ enrichRowsWithFkLabels(itemDicts, FileItem, db=appInterface.db)
return handleFilterValuesInMemory(itemDicts, column, pagination)
if mode == "ids":
@@ -797,7 +797,7 @@ def get_files(
# No grouping: let DB handle pagination directly (fastest path)
result = managementInterface.getAllFiles(pagination=paginationParams)
if paginationParams and hasattr(result, 'items'):
- enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem)
+ enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem, db=appInterface.db)
resp: dict = {
"items": enriched,
"pagination": PaginationMetadata(
@@ -811,7 +811,7 @@ def get_files(
}
else:
items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result])
- resp = {"items": enrichRowsWithFkLabels(_filesToDicts(items), FileItem), "pagination": None}
+ resp = {"items": enrichRowsWithFkLabels(_filesToDicts(items), FileItem, db=appInterface.db), "pagination": None}
if viewMeta:
resp["appliedView"] = viewMeta.model_dump()
return resp
@@ -821,7 +821,7 @@ def get_files(
allItems = enrichRowsWithFkLabels(
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
FileItem,
- db=managementInterface.db,
+ db=appInterface.db,
)
from modules.interfaces.interfaceTableHelpers import apply_strategy_b_filters_and_sort
@@ -1401,7 +1401,8 @@ def get_file(
)
fileDict = fileData.model_dump() if hasattr(fileData, "model_dump") else dict(fileData)
- enriched = enrichRowsWithFkLabels([fileDict], FileItem)
+ import modules.interfaces.interfaceDbApp as _appIface
+ enriched = enrichRowsWithFkLabels([fileDict], FileItem, db=_appIface.getInterface(currentUser).db)
return enriched[0]
except interfaceDbManagement.FileNotFoundError as e:
diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py
index 668c16ed..1abf1dff 100644
--- a/modules/routes/routeDataMandates.py
+++ b/modules/routes/routeDataMandates.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Mandate routes for the backend API.
diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py
index 4d46630c..bbb566e7 100644
--- a/modules/routes/routeDataPrompts.py
+++ b/modules/routes/routeDataPrompts.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query
from typing import List, Dict, Any, Optional, Tuple
@@ -367,4 +367,4 @@ def delete_prompt(
detail=routeApiMsg("Error deleting the prompt")
)
- return {"message": f"Prompt with ID {promptId} successfully deleted"}
\ No newline at end of file
+ return {"message": f"Prompt with ID {promptId} successfully deleted"}
diff --git a/modules/routes/routeDataSources.py b/modules/routes/routeDataSources.py
index e1a6ee39..7dc3dcba 100644
--- a/modules/routes/routeDataSources.py
+++ b/modules/routes/routeDataSources.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""DataSource auxiliary endpoints: settings (ragLimits) and cost estimate.
diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py
index e371b547..d0f805bd 100644
--- a/modules/routes/routeDataUsers.py
+++ b/modules/routes/routeDataUsers.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
User routes for the backend API.
diff --git a/modules/routes/routeGdpr.py b/modules/routes/routeGdpr.py
index d07df8a8..7c697c38 100644
--- a/modules/routes/routeGdpr.py
+++ b/modules/routes/routeGdpr.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
GDPR compliance routes for the backend API.
diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py
index 8b1b46d5..fd42fe8d 100644
--- a/modules/routes/routeI18n.py
+++ b/modules/routes/routeI18n.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Public and authenticated routes for UI language sets (DB-backed i18n).
diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py
index 25049227..e6325d2c 100644
--- a/modules/routes/routeInvitations.py
+++ b/modules/routes/routeInvitations.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Invitation routes for the backend API.
diff --git a/modules/routes/routeJobs.py b/modules/routes/routeJobs.py
index 9cd89d46..c98c7bd0 100644
--- a/modules/routes/routeJobs.py
+++ b/modules/routes/routeJobs.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""HTTP API for the generic background job service.
diff --git a/modules/routes/routeMfa.py b/modules/routes/routeMfa.py
index cb681fe0..0d3e4d59 100644
--- a/modules/routes/routeMfa.py
+++ b/modules/routes/routeMfa.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Routes for TOTP-based Multi-Factor Authentication.
diff --git a/modules/routes/routeNotifications.py b/modules/routes/routeNotifications.py
index c1cacb17..ef63fc1a 100644
--- a/modules/routes/routeNotifications.py
+++ b/modules/routes/routeNotifications.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Notification routes for in-app notifications.
diff --git a/modules/routes/routeRagInventory.py b/modules/routes/routeRagInventory.py
index 419ddec1..82348d9a 100644
--- a/modules/routes/routeRagInventory.py
+++ b/modules/routes/routeRagInventory.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""RAG Inventory API — global knowledge-store visibility for users, admins, platform."""
diff --git a/modules/routes/routeSecurityClickup.py b/modules/routes/routeSecurityClickup.py
index 935509bc..a61ed9d2 100644
--- a/modules/routes/routeSecurityClickup.py
+++ b/modules/routes/routeSecurityClickup.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp OAuth for data connections (UserConnection + Token)."""
diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py
index 2f1eabd2..a363f7bf 100644
--- a/modules/routes/routeSecurityGoogle.py
+++ b/modules/routes/routeSecurityGoogle.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Routes for Google authentication — split Auth app vs Data app.
diff --git a/modules/routes/routeSecurityInfomaniak.py b/modules/routes/routeSecurityInfomaniak.py
index 4026f4e9..14f2181a 100644
--- a/modules/routes/routeSecurityInfomaniak.py
+++ b/modules/routes/routeSecurityInfomaniak.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Infomaniak Personal-Access-Token onboarding for data connections.
diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py
index 6a25ce04..ee2f6390 100644
--- a/modules/routes/routeSecurityLocal.py
+++ b/modules/routes/routeSecurityLocal.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Routes for local security and authentication.
diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py
index c26503ef..45d3deda 100644
--- a/modules/routes/routeSecurityMsft.py
+++ b/modules/routes/routeSecurityMsft.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Routes for Microsoft authentication — split Auth app vs Data app.
diff --git a/modules/routes/routeSharepoint.py b/modules/routes/routeSharepoint.py
index 328a1bba..5c8c91bc 100644
--- a/modules/routes/routeSharepoint.py
+++ b/modules/routes/routeSharepoint.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
SharePoint routes for folder browsing
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index 7356f1aa..8ecb2013 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Feature Store routes.
diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py
index 87d34836..709d70e5 100644
--- a/modules/routes/routeSubscription.py
+++ b/modules/routes/routeSubscription.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Subscription routes — ID-based, state-machine-driven.
diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py
index 853c4b32..b47a3602 100644
--- a/modules/routes/routeSystem.py
+++ b/modules/routes/routeSystem.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
System Routes - Navigation and system-level API endpoints.
diff --git a/modules/routes/routeTableViews.py b/modules/routes/routeTableViews.py
index 32a4cf7d..80b27fa5 100644
--- a/modules/routes/routeTableViews.py
+++ b/modules/routes/routeTableViews.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CRUD endpoints for saved table views (TableListView).
diff --git a/modules/routes/routeUdb.py b/modules/routes/routeUdb.py
index 73c7b55c..9a2a09d8 100644
--- a/modules/routes/routeUdb.py
+++ b/modules/routes/routeUdb.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Generic UDB (Unified Data Bar) router.
diff --git a/modules/routes/routeVoiceGoogle.py b/modules/routes/routeVoiceGoogle.py
index 10185cc2..ada38504 100644
--- a/modules/routes/routeVoiceGoogle.py
+++ b/modules/routes/routeVoiceGoogle.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Google Cloud Voice Services Routes
diff --git a/modules/routes/routeVoiceUser.py b/modules/routes/routeVoiceUser.py
index ce14afe0..651a3baa 100644
--- a/modules/routes/routeVoiceUser.py
+++ b/modules/routes/routeVoiceUser.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
User-scoped voice settings and TTS/STT catalog endpoints.
diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py
index afd4aaa0..4fb7cca9 100644
--- a/modules/routes/routeWorkflowAutomation.py
+++ b/modules/routes/routeWorkflowAutomation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Mandatsweite WorkflowAutomation API.
diff --git a/modules/security/__init__.py b/modules/security/__init__.py
index bdf934d9..481be3b2 100644
--- a/modules/security/__init__.py
+++ b/modules/security/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Security core modules for low-level security operations.
diff --git a/modules/security/passwordUtils.py b/modules/security/passwordUtils.py
index 6d6ce235..8b20f399 100644
--- a/modules/security/passwordUtils.py
+++ b/modules/security/passwordUtils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Password utility functions for secure password handling.
diff --git a/modules/security/rbac.py b/modules/security/rbac.py
index 59f8f55f..f043febb 100644
--- a/modules/security/rbac.py
+++ b/modules/security/rbac.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
RBAC interface: Core RBAC logic and permission resolution.
diff --git a/modules/security/rbacCatalog.py b/modules/security/rbacCatalog.py
index 9b1ca22f..a6ee5b33 100644
--- a/modules/security/rbacCatalog.py
+++ b/modules/security/rbacCatalog.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
RBAC Catalog Service.
diff --git a/modules/security/rbacHelpers.py b/modules/security/rbacHelpers.py
index a191e5d8..7dfb30c9 100644
--- a/modules/security/rbacHelpers.py
+++ b/modules/security/rbacHelpers.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
RBAC helper functions for resource access control.
diff --git a/modules/security/rootAccess.py b/modules/security/rootAccess.py
index 1735891d..d2ed8ae2 100644
--- a/modules/security/rootAccess.py
+++ b/modules/security/rootAccess.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Root access management for system-level operations.
diff --git a/modules/serviceCenter/__init__.py b/modules/serviceCenter/__init__.py
index 968b8acf..38ab047e 100644
--- a/modules/serviceCenter/__init__.py
+++ b/modules/serviceCenter/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Service Center.
diff --git a/modules/serviceCenter/context.py b/modules/serviceCenter/context.py
index 2738f8b3..5b58d810 100644
--- a/modules/serviceCenter/context.py
+++ b/modules/serviceCenter/context.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Service Center Context.
diff --git a/modules/serviceCenter/core/__init__.py b/modules/serviceCenter/core/__init__.py
index 752c63b8..226125ab 100644
--- a/modules/serviceCenter/core/__init__.py
+++ b/modules/serviceCenter/core/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Core services - internal building blocks, not requested by features."""
diff --git a/modules/serviceCenter/core/flagResolution.py b/modules/serviceCenter/core/flagResolution.py
index 69edc3c2..b8ec3b43 100644
--- a/modules/serviceCenter/core/flagResolution.py
+++ b/modules/serviceCenter/core/flagResolution.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Cascade-inherit semantics for DataSource flags (neutralize, ragIndexEnabled).
diff --git a/modules/serviceCenter/core/serviceSecurity/__init__.py b/modules/serviceCenter/core/serviceSecurity/__init__.py
index 78f84b42..55039e2e 100644
--- a/modules/serviceCenter/core/serviceSecurity/__init__.py
+++ b/modules/serviceCenter/core/serviceSecurity/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Security core service."""
diff --git a/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py b/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py
index b5a9a84b..0904121d 100644
--- a/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py
+++ b/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Security service for token management operations.
diff --git a/modules/serviceCenter/core/serviceStreaming/__init__.py b/modules/serviceCenter/core/serviceStreaming/__init__.py
index 18a34f4e..ee1aa88c 100644
--- a/modules/serviceCenter/core/serviceStreaming/__init__.py
+++ b/modules/serviceCenter/core/serviceStreaming/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Streaming core service for SSE event management."""
diff --git a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py
index 76369553..98939b0f 100644
--- a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py
+++ b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Streaming service for SSE event management.
diff --git a/modules/serviceCenter/core/serviceUtils/__init__.py b/modules/serviceCenter/core/serviceUtils/__init__.py
index b3661f8d..f796aa66 100644
--- a/modules/serviceCenter/core/serviceUtils/__init__.py
+++ b/modules/serviceCenter/core/serviceUtils/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Utils core service."""
diff --git a/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py b/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py
index 856514bf..f6dbcbba 100644
--- a/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py
+++ b/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Utility service for common operations across the gateway.
diff --git a/modules/serviceCenter/core/types.py b/modules/serviceCenter/core/types.py
index 19c15081..f5b5d090 100644
--- a/modules/serviceCenter/core/types.py
+++ b/modules/serviceCenter/core/types.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Neutral protocol types used across serviceCenter services.
diff --git a/modules/serviceCenter/registry.py b/modules/serviceCenter/registry.py
index 64003d29..0bdd2205 100644
--- a/modules/serviceCenter/registry.py
+++ b/modules/serviceCenter/registry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Service Center Registry.
diff --git a/modules/serviceCenter/resolver.py b/modules/serviceCenter/resolver.py
index 729adb69..e92359b4 100644
--- a/modules/serviceCenter/resolver.py
+++ b/modules/serviceCenter/resolver.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Service Center Resolver.
diff --git a/modules/serviceCenter/services/__init__.py b/modules/serviceCenter/services/__init__.py
index 3f161a0f..2b05ef33 100644
--- a/modules/serviceCenter/services/__init__.py
+++ b/modules/serviceCenter/services/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Importable services - feature-facing, RBAC-protected."""
diff --git a/modules/serviceCenter/services/serviceAgent/__init__.py b/modules/serviceCenter/services/serviceAgent/__init__.py
index 8878ece1..eaf9ab4a 100644
--- a/modules/serviceCenter/services/serviceAgent/__init__.py
+++ b/modules/serviceCenter/services/serviceAgent/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""serviceAgent: AI Agent with ReAct loop and native function calling."""
diff --git a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py
index 4cfbb8c4..39e10d88 100644
--- a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py
+++ b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ActionToolAdapter: wraps existing workflow actions (dynamicMode=True) as agent tools."""
diff --git a/modules/serviceCenter/services/serviceAgent/agentLoop.py b/modules/serviceCenter/services/serviceAgent/agentLoop.py
index 99f4dbd7..f2ca07ed 100644
--- a/modules/serviceCenter/services/serviceAgent/agentLoop.py
+++ b/modules/serviceCenter/services/serviceAgent/agentLoop.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Agent loop: ReAct pattern with native function calling, budget control, and error handling."""
diff --git a/modules/serviceCenter/services/serviceAgent/conversationManager.py b/modules/serviceCenter/services/serviceAgent/conversationManager.py
index 055eae25..c8dfcc36 100644
--- a/modules/serviceCenter/services/serviceAgent/conversationManager.py
+++ b/modules/serviceCenter/services/serviceAgent/conversationManager.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Conversation manager for the Agent service.
Handles message history, context window management, and progressive summarization."""
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/__init__.py b/modules/serviceCenter/services/serviceAgent/coreTools/__init__.py
index e476ac39..c984f59a 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/__init__.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Core agent tools: registration of built-in ToolRegistry handlers."""
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py
index 0a9e678b..194783ca 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""External connection tools (list connections, upload, send mail)."""
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py
index 2675257c..eea4d084 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Cross-workflow tools and core-only tool-set tagging."""
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
index f1e49368..59dfa1d9 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""DataSource convenience tools (browse, search, download from external sources)."""
@@ -109,18 +109,15 @@ def registerDataSourceTools(registry: ToolRegistry, services):
"""Auto-extracted from registerCoreTools."""
def _buildResolverDb():
- """Build a DB adapter that ConnectorResolver can use to load UserConnections.
- interfaceDbApp has getUserConnectionById; ConnectorResolver expects getUserConnection."""
+ """Build a DB adapter that ConnectorResolver can use to load UserConnections."""
chatService = services.chat
- appIf = getattr(chatService, "interfaceDbApp", None)
- if appIf and hasattr(appIf, "getUserConnectionById"):
- class _Adapter:
- def __init__(self, app):
- self._app = app
- def getUserConnection(self, connectionId: str):
- return self._app.getUserConnectionById(connectionId)
- return _Adapter(appIf)
- return getattr(chatService, "interfaceDbComponent", None)
+
+ class _Adapter:
+ def __init__(self, svc):
+ self._svc = svc
+ def getUserConnection(self, connectionId: str):
+ return self._svc.getUserConnectionById(connectionId)
+ return _Adapter(chatService)
# ---- DataSource convenience tools ----
# Maps the FE-side `sourceType` literal (see SourcesTab.tsx
@@ -152,10 +149,7 @@ def registerDataSourceTools(registry: ToolRegistry, services):
path = ds.get("path", "/")
label = ds.get("label", "")
from modules.serviceCenter.core.flagResolution import getEffectiveFlag
- from modules.datamodels.datamodelDataSource import DataSource
- from modules.interfaces.interfaceDbApp import getRootInterface
- rootIf = getRootInterface()
- allConnDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
+ allConnDs = chatService.getDataSourcesByConnection(connectionId) if connectionId else [ds]
neutralize = bool(getEffectiveFlag(ds, "neutralize", allConnDs or [ds], mode="walk"))
service = _SOURCE_TYPE_TO_SERVICE.get(sourceType, sourceType)
if not connectionId:
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py
index a79f5995..baaab91c 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Document and vision tools (containers, content objects, image description)."""
@@ -455,14 +455,11 @@ def registerDocumentTools(registry: ToolRegistry, services):
_opType = OTE.IMAGE_ANALYSE
try:
- from modules.datamodels.datamodelFiles import FileItem
- from modules.interfaces.interfaceDbManagement import ComponentObjects
- _fRow = ComponentObjects().db._loadRecord(FileItem, fileId)
- if _fRow:
- _fGet = (lambda k, d=None: _fRow.get(k, d)) if isinstance(_fRow, dict) else (lambda k, d=None: getattr(_fRow, k, d))
- if bool(_fGet("neutralize", False)):
- _opType = OTE.NEUTRALIZATION_IMAGE
- logger.info(f"describeImage: file {fileId} has neutralize=True, using NEUTRALIZATION_IMAGE (internal models only)")
+ _chatSvc = services.chat
+ _fInfo = _chatSvc.getFileInfo(fileId) if hasattr(_chatSvc, "getFileInfo") else None
+ if _fInfo and _fInfo.get("neutralize", False):
+ _opType = OTE.NEUTRALIZATION_IMAGE
+ logger.info(f"describeImage: file {fileId} has neutralize=True, using NEUTRALIZATION_IMAGE (internal models only)")
except Exception as e:
logger.warning(f"describeImage: neutralize flag check failed for {fileId}: {e}")
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py
index d36a2727..a896c56c 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Email management agent tools (reply, forward, move, delete, flag, folder ops).
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
index f522a62a..b3d42944 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Feature Data Sub-Agent tool (queryFeatureInstance)."""
@@ -76,11 +76,10 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services):
)
try:
from modules.serviceCenter.services.serviceAgent.featureDataAgent import runFeatureDataAgent
- from modules.datamodels.datamodelFeatures import FeatureDataSource
- from modules.interfaces.interfaceDbApp import getRootInterface
+ from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds
- rootIf = getRootInterface()
- instance = rootIf.getFeatureInstance(featureInstanceId)
+ chatService = services.chat
+ instance = chatService.getFeatureInstance(featureInstanceId)
if not instance:
return ToolResult(
toolCallId="", toolName="queryFeatureInstance",
@@ -92,24 +91,11 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services):
instanceLabel = instance.label or ""
userId = context.get("userId", "")
requestLang = None
- if userId:
- langUser = rootIf.getUser(userId)
- if langUser:
- requestLang = getattr(langUser, "language", None)
+ if userId and hasattr(chatService, "user") and chatService.user:
+ requestLang = getattr(chatService.user, "language", None)
- rootDbConn = rootIf.db if hasattr(rootIf, "db") else None
- if rootDbConn is None:
- return ToolResult(
- toolCallId="", toolName="queryFeatureInstance",
- success=False, error="No database connector available",
- )
+ featureDataSources = chatService.getFeatureDataSources(featureInstanceId)
- featureDataSources = rootDbConn.getRecordset(
- FeatureDataSource,
- recordFilter={"featureInstanceId": featureInstanceId},
- )
-
- from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds
_fdsAll = featureDataSources or []
_anySourceNeutralize = any(
getEffectiveFlagFds(ds, "neutralize", _fdsAll, mode="walk") is True
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py
index 4e69d849..4546445d 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Shared helpers for core agent tools (file scope, binary detection, group helpers)."""
@@ -13,8 +13,8 @@ _MAX_TOOL_RESULT_CHARS = 50_000
_BINARY_SIGNATURES = (b"%PDF", b"\x89PNG", b"\xff\xd8\xff", b"GIF8", b"PK\x03\x04", b"Rar!", b"\x1f\x8b")
-def _resolveFileScope(fileId: str, context: dict) -> tuple:
- """Resolve featureInstanceId and mandateId for a file from context or management DB.
+def _resolveFileScope(fileId: str, context: dict, chatService=None) -> tuple:
+ """Resolve featureInstanceId and mandateId for a file from context or chat service.
Returns (featureInstanceId, mandateId) — never None, always strings.
"""
@@ -23,13 +23,11 @@ def _resolveFileScope(fileId: str, context: dict) -> tuple:
if fiId and mId:
return fiId, mId
try:
- from modules.datamodels.datamodelFiles import FileItem
- from modules.interfaces.interfaceDbManagement import ComponentObjects
- fm = ComponentObjects().db._loadRecord(FileItem, fileId)
- if fm:
- _get = (lambda k: fm.get(k, "")) if isinstance(fm, dict) else (lambda k: getattr(fm, k, ""))
- fiId = fiId or str(_get("featureInstanceId") or "")
- mId = mId or str(_get("mandateId") or "")
+ if chatService:
+ fileInfo = chatService.getFileInfo(fileId)
+ if fileInfo:
+ fiId = fiId or str(fileInfo.get("featureInstanceId") or "")
+ mId = mId or str(fileInfo.get("mandateId") or "")
except Exception as e:
logger.warning(f"_resolveFileScope failed for fileId={fileId}: {e}")
return fiId, mId
@@ -57,7 +55,7 @@ def _getOrCreateTempFolder(chatService) -> Optional[str]:
return str(folderId) if folderId else None
newFolder = chatService.createFolder("Temp")
folderId = newFolder.get("id") if isinstance(newFolder, dict) else getattr(newFolder, "id", None)
- userId = getattr(getattr(chatService, "interfaceDbComponent", None), "userId", None)
+ userId = getattr(chatService.user, "id", None) if hasattr(chatService, "user") else None
logger.info("_getOrCreateTempFolder: created Temp folder %s for user %s", folderId, userId)
return str(folderId) if folderId else None
except Exception as e:
@@ -218,19 +216,16 @@ def _formatToolFileResult(
def _buildResolverDbFromServices(services: Any):
"""DB adapter for ConnectorResolver: load UserConnections by id.
- interfaceDbApp exposes getUserConnectionById; ConnectorResolver expects getUserConnection.
+ Wraps chatService.getUserConnectionById into the interface ConnectorResolver expects.
"""
chatService = services.chat
- appIf = getattr(chatService, "interfaceDbApp", None)
- if appIf and hasattr(appIf, "getUserConnectionById"):
- class _Adapter:
- def __init__(self, app):
- self._app = app
+ class _Adapter:
+ def __init__(self, svc):
+ self._svc = svc
- def getUserConnection(self, connectionId: str):
- return self._app.getUserConnectionById(connectionId)
+ def getUserConnection(self, connectionId: str):
+ return self._svc.getUserConnectionById(connectionId)
- return _Adapter(appIf)
- return getattr(chatService, "interfaceDbComponent", None)
+ return _Adapter(chatService)
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py
index e3978b72..57f74513 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Media and utility tools (render, TTS, STT, image gen, charts, neutralize, code exec)."""
@@ -382,26 +382,14 @@ def registerMediaTools(registry: ToolRegistry, services):
if not voiceName:
try:
- from modules.datamodels.datamodelUam import UserVoicePreferences
- from modules.interfaces.interfaceDbApp import getRootInterface
userId = context.get("userId", "")
if userId:
- rootIf = getRootInterface()
- prefRecords = rootIf.db.getRecordset(
- UserVoicePreferences,
- recordFilter={"userId": userId}
- )
- if prefRecords:
- allPrefs = [
- r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else r
- for r in prefRecords
- ]
- _mid = str(mandateId or "").strip()
- scopedPref = next((p for p in allPrefs if str(p.get("mandateId") or "").strip() == _mid), None)
- globalPref = next((p for p in allPrefs if not str(p.get("mandateId") or "").strip()), None)
-
- def _resolveVoiceFromMap(prefDict, lang):
- vm = (prefDict or {}).get("ttsVoiceMap", {}) or {}
+ chatService = services.chat
+ mandateIdVal = str(mandateId or "").strip()
+ prefDict = chatService.getUserVoicePreferences(userId, mandateIdVal) if hasattr(chatService, "getUserVoicePreferences") else None
+ if prefDict:
+ def _resolveVoiceFromMap(prefRec, lang):
+ vm = (prefRec or {}).get("ttsVoiceMap", {}) or {}
if not isinstance(vm, dict) or not vm:
return None
baseLang = lang.split("-")[0].lower() if isinstance(lang, str) and lang else ""
@@ -419,16 +407,10 @@ def registerMediaTools(registry: ToolRegistry, services):
return mv.get("voiceName") if isinstance(mv, dict) else mv
return None
- voiceName = (
- _resolveVoiceFromMap(scopedPref, language)
- or _resolveVoiceFromMap(globalPref, language)
- or _resolveVoiceFromMap(allPrefs[0], language)
- )
+ voiceName = _resolveVoiceFromMap(prefDict, language)
if not voiceName:
- for candidate in [globalPref, scopedPref, allPrefs[0]]:
- if candidate and candidate.get("ttsVoice") and candidate.get("ttsLanguage") == language:
- voiceName = candidate["ttsVoice"]
- break
+ if prefDict.get("ttsVoice") and prefDict.get("ttsLanguage") == language:
+ voiceName = prefDict["ttsVoice"]
if voiceName:
logger.info(f"textToSpeech: using configured voice '{voiceName}' for language '{language}'")
except Exception as prefErr:
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py
index a1d56e24..4fc47e60 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Workspace and file management tools (read, write, search, folders, web, translate)."""
@@ -150,16 +150,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services):
try:
text = rawBytes.decode(encoding)
if text.strip():
- _fileNeedNeutralize = False
- try:
- from modules.datamodels.datamodelFiles import FileItem
- from modules.interfaces.interfaceDbManagement import ComponentObjects
- _fRec = ComponentObjects().db._loadRecord(FileItem, fileId)
- if _fRec:
- _fG = (lambda k, d=None: _fRec.get(k, d)) if isinstance(_fRec, dict) else (lambda k, d=None: getattr(_fRec, k, d))
- _fileNeedNeutralize = bool(_fG("neutralize", False))
- except Exception as e:
- logger.warning(f"readFile: neutralize flag check failed for {fileId}: {e}")
+ _fileNeedNeutralize = fileInfo.get("neutralize", False) if fileInfo else False
if _fileNeedNeutralize:
try:
_nSvc = services.getService("neutralization") if hasattr(services, "getService") else None
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py
index d2a76c9f..f8d8c76b 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Orchestrator: registers all core agent tools by delegating to domain modules."""
diff --git a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py
index 9c94247d..e2c76eab 100644
--- a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Data models for the Agent service."""
diff --git a/modules/serviceCenter/services/serviceAgent/datamodelOntology.py b/modules/serviceCenter/services/serviceAgent/datamodelOntology.py
index 30e5b023..2dc3ef6c 100644
--- a/modules/serviceCenter/services/serviceAgent/datamodelOntology.py
+++ b/modules/serviceCenter/services/serviceAgent/datamodelOntology.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Ontology data model for feature data sub-agents.
diff --git a/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py b/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py
index cfa24a2b..5ef41e74 100644
--- a/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py
+++ b/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
External agent-tool provider registry.
diff --git a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
index 117645dc..e786030a 100644
--- a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Feature Data Sub-Agent.
diff --git a/modules/serviceCenter/services/serviceAgent/featureDataProvider.py b/modules/serviceCenter/services/serviceAgent/featureDataProvider.py
index eec9fcca..c129a267 100644
--- a/modules/serviceCenter/services/serviceAgent/featureDataProvider.py
+++ b/modules/serviceCenter/services/serviceAgent/featureDataProvider.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Generic data provider for querying feature-instance tables.
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index 390d062c..81fc7f29 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Agent service: entry point for running AI agents with tool use."""
diff --git a/modules/serviceCenter/services/serviceAgent/ontologyToPromptCompiler.py b/modules/serviceCenter/services/serviceAgent/ontologyToPromptCompiler.py
index 5b162ed3..d056b0f2 100644
--- a/modules/serviceCenter/services/serviceAgent/ontologyToPromptCompiler.py
+++ b/modules/serviceCenter/services/serviceAgent/ontologyToPromptCompiler.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Deterministic compiler: OntologyDescriptor -> sub-agent prompt block.
diff --git a/modules/serviceCenter/services/serviceAgent/queryValidator.py b/modules/serviceCenter/services/serviceAgent/queryValidator.py
index 2dbbd57e..3f3e8c3d 100644
--- a/modules/serviceCenter/services/serviceAgent/queryValidator.py
+++ b/modules/serviceCenter/services/serviceAgent/queryValidator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Pre-execute query validator for the Feature Data Sub-Agent.
diff --git a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py
index 395c674e..512a9322 100644
--- a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py
+++ b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Sandboxed code execution for the AI agent executeCode tool."""
@@ -101,10 +101,10 @@ class _VirtualFS:
def _makeReadFile(services):
"""Create a readFile(fileId) closure bound to the current services context."""
def readFile(fileId: str, encoding: str = "utf-8") -> str:
- mgmt = getattr(services, 'interfaceDbComponent', None) if services else None
- if not mgmt:
+ chatService = getattr(services, 'chat', None) if services else None
+ if not chatService or not hasattr(chatService, 'getFileData'):
raise RuntimeError("readFile: no file store available in this session")
- data = mgmt.getFileData(str(fileId))
+ data = chatService.getFileData(str(fileId))
if data is None:
raise FileNotFoundError(f"File '{fileId}' not found in workspace")
try:
@@ -120,10 +120,10 @@ _MAX_FILE_BYTES = 50_000_000 # 50 MB safety limit
def _makeReadFileBytes(services):
"""Create a readFileBytes(fileId) closure for binary file access in the sandbox."""
def readFileBytes(fileId: str) -> bytes:
- mgmt = getattr(services, 'interfaceDbComponent', None) if services else None
- if not mgmt:
+ chatService = getattr(services, 'chat', None) if services else None
+ if not chatService or not hasattr(chatService, 'getFileData'):
raise RuntimeError("readFileBytes: no file store available in this session")
- data = mgmt.getFileData(str(fileId))
+ data = chatService.getFileData(str(fileId))
if data is None:
raise FileNotFoundError(f"File '{fileId}' not found in workspace")
if len(data) > _MAX_FILE_BYTES:
diff --git a/modules/serviceCenter/services/serviceAgent/toolRegistry.py b/modules/serviceCenter/services/serviceAgent/toolRegistry.py
index b2ba67a0..68dd621c 100644
--- a/modules/serviceCenter/services/serviceAgent/toolRegistry.py
+++ b/modules/serviceCenter/services/serviceAgent/toolRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Tool registry for the Agent service. Manages tool definitions and dispatch."""
diff --git a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py
index a464525a..36c44910 100644
--- a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py
+++ b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Toolbox Registry for the Agent service.
diff --git a/modules/serviceCenter/services/serviceAi/__init__.py b/modules/serviceCenter/services/serviceAi/__init__.py
index c7f7d39c..5e64fe2a 100644
--- a/modules/serviceCenter/services/serviceAi/__init__.py
+++ b/modules/serviceCenter/services/serviceAi/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""AI service."""
diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
index 98489dc8..79389b21 100644
--- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py
+++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import json
import logging
diff --git a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py
index ea218e11..1044d1db 100644
--- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py
+++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
AI Call Looping Module
diff --git a/modules/serviceCenter/services/serviceAi/subContentExtraction.py b/modules/serviceCenter/services/serviceAi/subContentExtraction.py
index d66db1cc..59c90e21 100644
--- a/modules/serviceCenter/services/serviceAi/subContentExtraction.py
+++ b/modules/serviceCenter/services/serviceAi/subContentExtraction.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Content Extraction Module
diff --git a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py
index aae86fc2..ed01ef4c 100644
--- a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py
+++ b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Document Intent Analysis Module
diff --git a/modules/serviceCenter/services/serviceAi/subJsonMerger.py b/modules/serviceCenter/services/serviceAi/subJsonMerger.py
index 6b4e6c5e..33ff72d6 100644
--- a/modules/serviceCenter/services/serviceAi/subJsonMerger.py
+++ b/modules/serviceCenter/services/serviceAi/subJsonMerger.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Modular JSON Merger - Intelligent JSON Fragment Merging
diff --git a/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py b/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py
index 1945c550..92007825 100644
--- a/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py
+++ b/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
JSON Response Handling Module
diff --git a/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py b/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py
index fa52fdac..5b67c822 100644
--- a/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py
+++ b/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Generic Looping Use Case System
diff --git a/modules/serviceCenter/services/serviceAi/subResponseParsing.py b/modules/serviceCenter/services/serviceAi/subResponseParsing.py
index 68c123ac..fe8b09e6 100644
--- a/modules/serviceCenter/services/serviceAi/subResponseParsing.py
+++ b/modules/serviceCenter/services/serviceAi/subResponseParsing.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Response Parsing Module
diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py
index c2e580a4..eb1e1d7d 100644
--- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py
+++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Structure Filling Module
diff --git a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py
index cee66f60..72c7cb50 100644
--- a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py
+++ b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Structure Generation Module
diff --git a/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py b/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py
index ce67dc4a..a5b197a5 100644
--- a/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py
+++ b/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Background job service: generic, reusable infrastructure for long-running tasks."""
diff --git a/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py b/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py
index 6ac1cbee..7beb987b 100644
--- a/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py
+++ b/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Background job service.
diff --git a/modules/serviceCenter/services/serviceBilling/__init__.py b/modules/serviceCenter/services/serviceBilling/__init__.py
index 55d95d1a..3e74a0f2 100644
--- a/modules/serviceCenter/services/serviceBilling/__init__.py
+++ b/modules/serviceCenter/services/serviceBilling/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Billing service."""
diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py
index 9076f9e0..6985aff9 100644
--- a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py
+++ b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
When the shared mandate pool (PREPAY_MANDATE) is exhausted, notify mandate admins.
diff --git a/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py b/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py
index 8e765cc7..5aba9e94 100644
--- a/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py
+++ b/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Stripe webhook and subscription business logic for billing.
diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
index 2158506f..4dcf725c 100644
--- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
+++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Billing Service - Central service for billing operations.
diff --git a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py
index 010c4e4b..a25c4717 100644
--- a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py
+++ b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Stripe Checkout service for billing credit top-ups.
diff --git a/modules/serviceCenter/services/serviceChat/__init__.py b/modules/serviceCenter/services/serviceChat/__init__.py
index a776b886..6a977f18 100644
--- a/modules/serviceCenter/services/serviceChat/__init__.py
+++ b/modules/serviceCenter/services/serviceChat/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Chat service."""
diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
index 3382f75e..18ab2a68 100644
--- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py
+++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Chat service for document processing, chat operations, and workflow management."""
import logging
@@ -481,6 +481,9 @@ class ChatService:
"tags": getattr(fileItem, "tags", None),
"description": getattr(fileItem, "description", None),
"status": getattr(fileItem, "status", None),
+ "neutralize": bool(getattr(fileItem, "neutralize", False)),
+ "featureInstanceId": getattr(fileItem, "featureInstanceId", None) or "",
+ "mandateId": getattr(fileItem, "mandateId", None) or "",
}
return None
@@ -595,6 +598,53 @@ class ChatService:
results = self.interfaceDbApp.db.getRecordset(DataSource, recordFilter={"id": dataSourceId})
return results[0] if results else None
+ def getDataSourcesByConnection(self, connectionId: str) -> List[Dict[str, Any]]:
+ """Get all DataSource records linked to a specific connectionId."""
+ from modules.datamodels.datamodelDataSource import DataSource
+ return self.interfaceDbApp.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId}) or []
+
+ def getFeatureInstance(self, featureInstanceId: str):
+ """Get a FeatureInstance record by ID."""
+ if not featureInstanceId or not self.interfaceDbApp:
+ return None
+ try:
+ return self.interfaceDbApp.getFeatureInstance(featureInstanceId)
+ except Exception as e:
+ logger.warning(f"getFeatureInstance({featureInstanceId}) failed: {e}")
+ return None
+
+ def getUserVoicePreferences(self, userId: str, mandateId: str = None) -> Optional[Dict[str, Any]]:
+ """Get TTS voice preferences for a user, resolved by mandate scope."""
+ from modules.datamodels.datamodelUam import UserVoicePreferences
+ try:
+ prefRecords = self.interfaceDbApp.db.getRecordset(
+ UserVoicePreferences, recordFilter={"userId": userId}
+ )
+ if not prefRecords:
+ return None
+ allPrefs = [
+ r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else r
+ for r in prefRecords
+ ]
+ _mid = str(mandateId or "").strip()
+ scopedPref = next((p for p in allPrefs if str(p.get("mandateId") or "").strip() == _mid), None) if _mid else None
+ globalPref = next((p for p in allPrefs if not str(p.get("mandateId") or "").strip()), None)
+ return scopedPref or globalPref
+ except Exception as e:
+ logger.warning(f"getUserVoicePreferences({userId}) failed: {e}")
+ return None
+
+ def getFeatureDataSources(self, featureInstanceId: str) -> List[Dict[str, Any]]:
+ """Get all FeatureDataSource records for a given featureInstanceId."""
+ from modules.datamodels.datamodelFeatures import FeatureDataSource
+ try:
+ return self.interfaceDbApp.db.getRecordset(
+ FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
+ ) or []
+ except Exception as e:
+ logger.warning(f"getFeatureDataSources({featureInstanceId}) failed: {e}")
+ return []
+
def deleteDataSource(self, dataSourceId: str) -> bool:
"""Delete a data source."""
from modules.datamodels.datamodelDataSource import DataSource
diff --git a/modules/serviceCenter/services/serviceClickup/__init__.py b/modules/serviceCenter/services/serviceClickup/__init__.py
index 49f56ec0..31f7b664 100644
--- a/modules/serviceCenter/services/serviceClickup/__init__.py
+++ b/modules/serviceCenter/services/serviceClickup/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp service."""
diff --git a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py
index 74a7e809..9570c184 100644
--- a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py
+++ b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp API service (OAuth or personal token via UserConnection).
diff --git a/modules/serviceCenter/services/serviceExtraction/__init__.py b/modules/serviceCenter/services/serviceExtraction/__init__.py
index 737a1900..dbbfca46 100644
--- a/modules/serviceCenter/services/serviceExtraction/__init__.py
+++ b/modules/serviceCenter/services/serviceExtraction/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from .mainServiceExtraction import ExtractionService
diff --git a/modules/serviceCenter/services/serviceExtraction/chunking/__init__.py b/modules/serviceCenter/services/serviceExtraction/chunking/__init__.py
index 085d67cf..e06ded01 100644
--- a/modules/serviceCenter/services/serviceExtraction/chunking/__init__.py
+++ b/modules/serviceCenter/services/serviceExtraction/chunking/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
diff --git a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerImage.py b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerImage.py
index d58d2139..340a18d0 100644
--- a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerImage.py
+++ b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerImage.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
import base64
diff --git a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerStructure.py b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerStructure.py
index fa65e19c..91f127ed 100644
--- a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerStructure.py
+++ b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerStructure.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
import json
diff --git a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerTable.py b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerTable.py
index e137711d..67a48d42 100644
--- a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerTable.py
+++ b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerTable.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
diff --git a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerText.py b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerText.py
index 330f9ea9..bea6e81e 100644
--- a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerText.py
+++ b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerText.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
import logging
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/__init__.py b/modules/serviceCenter/services/serviceExtraction/extractors/__init__.py
index 085d67cf..e06ded01 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/__init__.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorAudio.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorAudio.py
index a1f06f99..599b0285 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorAudio.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorAudio.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Audio extractor for common audio formats.
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorBinary.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorBinary.py
index f0048c7a..3f1ea95b 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorBinary.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorBinary.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
import base64
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py
index a69b2e35..3e33db52 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Container extractor for ZIP, TAR, GZ, and 7Z archives.
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorCsv.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorCsv.py
index 1f6bbaf6..ccf87d55 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorCsv.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorCsv.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorDocx.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorDocx.py
index c8e7c289..5e5ef8fa 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorDocx.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorDocx.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
import io
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py
index b557172f..6180f5d1 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Email extractor for EML and MSG files.
@@ -82,6 +82,9 @@ class EmailExtractor(Extractor):
data=headerText, metadata={"emailPart": "headers"},
))
+ hasPlainBody = False
+ htmlBodies: List[str] = []
+
for part in msg.walk():
contentType = part.get_content_type()
disposition = str(part.get("Content-Disposition", ""))
@@ -98,7 +101,8 @@ class EmailExtractor(Extractor):
if contentType == "text/plain":
body = part.get_content()
- if body:
+ if body and str(body).strip():
+ hasPlainBody = True
parts.append(ContentPart(
id=makeId(), parentId=rootId, label="body_text",
typeGroup="text", mimeType="text/plain",
@@ -107,10 +111,18 @@ class EmailExtractor(Extractor):
elif contentType == "text/html":
body = part.get_content()
if body:
+ htmlBodies.append(str(body))
+
+ if htmlBodies:
+ if hasPlainBody:
+ pass
+ else:
+ plainText = _htmlToPlainText(htmlBodies[0])
+ if plainText.strip():
parts.append(ContentPart(
- id=makeId(), parentId=rootId, label="body_html",
- typeGroup="text", mimeType="text/html",
- data=str(body), metadata={"emailPart": "body_html"},
+ id=makeId(), parentId=rootId, label="body_text",
+ typeGroup="text", mimeType="text/plain",
+ data=plainText, metadata={"emailPart": "body", "convertedFromHtml": True},
))
return parts
@@ -171,11 +183,14 @@ class EmailExtractor(Extractor):
if htmlBody:
if isinstance(htmlBody, bytes):
htmlBody = htmlBody.decode("utf-8", errors="replace")
- parts.append(ContentPart(
- id=makeId(), parentId=rootId, label="body_html",
- typeGroup="text", mimeType="text/html",
- data=htmlBody, metadata={"emailPart": "body_html"},
- ))
+ if not body or not body.strip():
+ plainText = _htmlToPlainText(htmlBody)
+ if plainText.strip():
+ parts.append(ContentPart(
+ id=makeId(), parentId=rootId, label="body_text",
+ typeGroup="text", mimeType="text/plain",
+ data=plainText, metadata={"emailPart": "body", "convertedFromHtml": True},
+ ))
for attachment in (msgFile.attachments or []):
attachName = getattr(attachment, "longFilename", None) or getattr(attachment, "shortFilename", None) or "attachment"
@@ -201,6 +216,24 @@ def _buildHeaderText(msg) -> str:
return "\n".join(lines)
+def _htmlToPlainText(html: str) -> str:
+ """Convert HTML email body to readable plain text, stripping all tags and noise."""
+ import re
+ try:
+ from bs4 import BeautifulSoup
+ soup = BeautifulSoup(html, "html.parser")
+ for tag in soup(["script", "style", "head", "meta", "link"]):
+ tag.decompose()
+ text = soup.get_text(separator="\n")
+ except Exception:
+ text = re.sub(r"<[^>]+>", " ", html)
+ lines = [line.strip() for line in text.splitlines()]
+ lines = [line for line in lines if line]
+ text = "\n".join(lines)
+ text = re.sub(r"\n{3,}", "\n\n", text)
+ return text.strip()
+
+
_MAX_CASCADE_DEPTH = 10
def _delegateAttachment(attachData: bytes, attachName: str, parentId: str, depth: int = 0) -> List[ContentPart]:
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py
index 51c8d9f5..0f81fce0 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Folder extractor -- treats a local folder reference as a container.
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorHtml.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorHtml.py
index c7e549bb..b840bdbc 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorHtml.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorHtml.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
from bs4 import BeautifulSoup
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorImage.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorImage.py
index 7f081176..8f51e70b 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorImage.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorImage.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
import base64
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorJson.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorJson.py
index a4ef705d..39f7b377 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorJson.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorJson.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
import json
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorPdf.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorPdf.py
index 657e3fc6..d87f3c55 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorPdf.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorPdf.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
import base64
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorPptx.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorPptx.py
index 0c811d20..a278a823 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorPptx.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorPptx.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import base64
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorSql.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorSql.py
index 01b1ba07..7b7e497e 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorSql.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorSql.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorText.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorText.py
index 92b1fc4a..764d6b4d 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorText.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorText.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorVideo.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorVideo.py
index 1b0513ce..517c5c53 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorVideo.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorVideo.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Video extractor for common video formats.
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorXlsx.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorXlsx.py
index a85902f3..fa45db0b 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorXlsx.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorXlsx.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
import io
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorXml.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorXml.py
index e264e774..900cd1f2 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorXml.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorXml.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
import xml.etree.ElementTree as ET
diff --git a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py
index 125281e7..ceed2162 100644
--- a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py
+++ b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Extraction service for document content extraction and processing."""
from typing import Any, Dict, List, Optional, Union, Callable
diff --git a/modules/serviceCenter/services/serviceExtraction/merging/__init__.py b/modules/serviceCenter/services/serviceExtraction/merging/__init__.py
index fdcc4f0e..06003961 100644
--- a/modules/serviceCenter/services/serviceExtraction/merging/__init__.py
+++ b/modules/serviceCenter/services/serviceExtraction/merging/__init__.py
@@ -1,2 +1,2 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
diff --git a/modules/serviceCenter/services/serviceExtraction/merging/mergerDefault.py b/modules/serviceCenter/services/serviceExtraction/merging/mergerDefault.py
index 3ea0fa82..573a9c6e 100644
--- a/modules/serviceCenter/services/serviceExtraction/merging/mergerDefault.py
+++ b/modules/serviceCenter/services/serviceExtraction/merging/mergerDefault.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
from modules.datamodels.datamodelExtraction import ContentPart, MergeStrategy
diff --git a/modules/serviceCenter/services/serviceExtraction/merging/mergerTable.py b/modules/serviceCenter/services/serviceExtraction/merging/mergerTable.py
index 6bdcfc11..c1090cf1 100644
--- a/modules/serviceCenter/services/serviceExtraction/merging/mergerTable.py
+++ b/modules/serviceCenter/services/serviceExtraction/merging/mergerTable.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
from modules.datamodels.datamodelExtraction import ContentPart, MergeStrategy
diff --git a/modules/serviceCenter/services/serviceExtraction/merging/mergerText.py b/modules/serviceCenter/services/serviceExtraction/merging/mergerText.py
index 591c4462..15c58070 100644
--- a/modules/serviceCenter/services/serviceExtraction/merging/mergerText.py
+++ b/modules/serviceCenter/services/serviceExtraction/merging/mergerText.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List
from modules.datamodels.datamodelExtraction import ContentPart, MergeStrategy
diff --git a/modules/serviceCenter/services/serviceExtraction/subMerger.py b/modules/serviceCenter/services/serviceExtraction/subMerger.py
index 003cbce2..147dad38 100644
--- a/modules/serviceCenter/services/serviceExtraction/subMerger.py
+++ b/modules/serviceCenter/services/serviceExtraction/subMerger.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Intelligent Token-Aware Merger for optimizing AI calls based on LLM token limits.
diff --git a/modules/serviceCenter/services/serviceExtraction/subPipeline.py b/modules/serviceCenter/services/serviceExtraction/subPipeline.py
index b76578ed..4717510d 100644
--- a/modules/serviceCenter/services/serviceExtraction/subPipeline.py
+++ b/modules/serviceCenter/services/serviceExtraction/subPipeline.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import List
import logging
diff --git a/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py b/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py
index 0f9cbf45..55fbe405 100644
--- a/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py
+++ b/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Prompt builder for document extraction.
diff --git a/modules/serviceCenter/services/serviceExtraction/subRegistry.py b/modules/serviceCenter/services/serviceExtraction/subRegistry.py
index 864afe65..7072ecbb 100644
--- a/modules/serviceCenter/services/serviceExtraction/subRegistry.py
+++ b/modules/serviceCenter/services/serviceExtraction/subRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List, Optional, TYPE_CHECKING
import logging
diff --git a/modules/serviceCenter/services/serviceExtraction/subUtils.py b/modules/serviceCenter/services/serviceExtraction/subUtils.py
index 2e3c3384..13e268cb 100644
--- a/modules/serviceCenter/services/serviceExtraction/subUtils.py
+++ b/modules/serviceCenter/services/serviceExtraction/subUtils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import uuid
diff --git a/modules/serviceCenter/services/serviceGeneration/__init__.py b/modules/serviceCenter/services/serviceGeneration/__init__.py
index 49e4ab4b..38191d73 100644
--- a/modules/serviceCenter/services/serviceGeneration/__init__.py
+++ b/modules/serviceCenter/services/serviceGeneration/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Generation service."""
diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
index 1137b7d6..08ab5127 100644
--- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
+++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import uuid
@@ -693,4 +693,4 @@ class GenerationService:
logger.error(f"Error getting renderer for {output_format}: {str(e)}")
# traceback is already imported at module level
logger.debug(traceback.format_exc())
- return None
\ No newline at end of file
+ return None
diff --git a/modules/serviceCenter/services/serviceGeneration/paths/codePath.py b/modules/serviceCenter/services/serviceGeneration/paths/codePath.py
index c7f76689..ac4f85bd 100644
--- a/modules/serviceCenter/services/serviceGeneration/paths/codePath.py
+++ b/modules/serviceCenter/services/serviceGeneration/paths/codePath.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Code Generation Path
diff --git a/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py b/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py
index b74286eb..e680b658 100644
--- a/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py
+++ b/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Document Generation Path
diff --git a/modules/serviceCenter/services/serviceGeneration/paths/imagePath.py b/modules/serviceCenter/services/serviceGeneration/paths/imagePath.py
index c61bc997..89d99941 100644
--- a/modules/serviceCenter/services/serviceGeneration/paths/imagePath.py
+++ b/modules/serviceCenter/services/serviceGeneration/paths/imagePath.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Image Generation Path
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/_pdfFontFallback.py b/modules/serviceCenter/services/serviceGeneration/renderers/_pdfFontFallback.py
index 8603c78f..2dad2b3d 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/_pdfFontFallback.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/_pdfFontFallback.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Inline emoji-font fallback for the ReportLab-based PDF renderer.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/codeRendererBaseTemplate.py b/modules/serviceCenter/services/serviceGeneration/renderers/codeRendererBaseTemplate.py
index d3586b8e..ed47240e 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/codeRendererBaseTemplate.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/codeRendererBaseTemplate.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Base renderer class for code format renderers.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py
index 9fc4d94b..cb44dbc0 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Base renderer class for all format renderers.
@@ -783,4 +783,4 @@ Requirements:
- Ensure all objects are properly closed with closing braces
- Only modify styles if style instructions are present in the user request
-Return the complete JSON:"""
\ No newline at end of file
+Return the complete JSON:"""
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/registry.py b/modules/serviceCenter/services/serviceGeneration/renderers/registry.py
index f0cea780..568c63c2 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/registry.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/registry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Renderer registry for automatic discovery and registration of renderers.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py
index e430c302..90aa95b8 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CSV code renderer for code generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py
index 143be000..ebf5175a 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
JSON code renderer for code generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py
index f4952679..7fd36e13 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
XML code renderer for code generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py
index d08fc1fe..4a0333bc 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CSV renderer for report generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py
index 7e427dd4..fdc2f3c4 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
DOCX renderer for report generation using python-docx.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py
index fe624723..fee4b2df 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
HTML renderer for report generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py
index 2c8524e3..2624c64f 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Image renderer for report generation using AI image generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py
index bc6b6a85..1de10105 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
JSON renderer for report generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py
index b2458f19..f0454690 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Markdown renderer for report generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
index a7df6875..0543a7f3 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
PDF renderer for report generation using reportlab.
@@ -1290,4 +1290,4 @@ class RendererPdf(BaseRenderer):
errorStyle = self._createNormalStyle(styles)
errorStyle.textColor = self._hexToColor("#FF0000") # Red color for error
errorMsg = f"[Error: Could not render image '{image_data.get('altText', 'Image')}'. {str(e)}]"
- return [Paragraph(errorMsg, errorStyle)]
\ No newline at end of file
+ return [Paragraph(errorMsg, errorStyle)]
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py
index 36d399d8..1547086f 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import base64
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py
index 1af2aec5..366139e8 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Text renderer for report generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py
index d82e4a55..aaa5d022 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Excel renderer for report generation using openpyxl.
diff --git a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py
index 8d60c282..abd21feb 100644
--- a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py
+++ b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Default style definitions and style resolution for document rendering."""
diff --git a/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py b/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py
index c8713fee..4a6c325a 100644
--- a/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py
+++ b/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Content Generator for hierarchical document generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/subContentIntegrator.py b/modules/serviceCenter/services/serviceGeneration/subContentIntegrator.py
index 1a83eb6e..3dcc057e 100644
--- a/modules/serviceCenter/services/serviceGeneration/subContentIntegrator.py
+++ b/modules/serviceCenter/services/serviceGeneration/subContentIntegrator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Content Integrator for hierarchical document generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py
index 21ba33d1..e70920a8 100644
--- a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py
+++ b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import json
import logging
@@ -542,4 +542,4 @@ def convertDocumentDataToString(document_data: Any, file_extension: str) -> str:
return str(document_data)
except Exception as e:
logger.error(f"Error converting document data to string: {str(e)}")
- return str(document_data)
\ No newline at end of file
+ return str(document_data)
diff --git a/modules/serviceCenter/services/serviceGeneration/subJsonSchema.py b/modules/serviceCenter/services/serviceGeneration/subJsonSchema.py
index 22359499..67c7db5c 100644
--- a/modules/serviceCenter/services/serviceGeneration/subJsonSchema.py
+++ b/modules/serviceCenter/services/serviceGeneration/subJsonSchema.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
JSON Schema definitions for AI-generated document structures (unified).
diff --git a/modules/serviceCenter/services/serviceGeneration/subPromptBuilderGeneration.py b/modules/serviceCenter/services/serviceGeneration/subPromptBuilderGeneration.py
index f0222dce..cd4462f2 100644
--- a/modules/serviceCenter/services/serviceGeneration/subPromptBuilderGeneration.py
+++ b/modules/serviceCenter/services/serviceGeneration/subPromptBuilderGeneration.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Prompt builder for document generation.
diff --git a/modules/serviceCenter/services/serviceGeneration/subStructureGenerator.py b/modules/serviceCenter/services/serviceGeneration/subStructureGenerator.py
index c2438fc0..cd9c4d11 100644
--- a/modules/serviceCenter/services/serviceGeneration/subStructureGenerator.py
+++ b/modules/serviceCenter/services/serviceGeneration/subStructureGenerator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Structure Generator for hierarchical document generation.
diff --git a/modules/serviceCenter/services/serviceKnowledge/__init__.py b/modules/serviceCenter/services/serviceKnowledge/__init__.py
index a5d1fc04..6dde9734 100644
--- a/modules/serviceCenter/services/serviceKnowledge/__init__.py
+++ b/modules/serviceCenter/services/serviceKnowledge/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""serviceKnowledge: 3-tier RAG Knowledge Store with semantic search."""
diff --git a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py
index cf32a925..e750e5a1 100644
--- a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py
+++ b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Generic UDB Tree builder.
diff --git a/modules/serviceCenter/services/serviceKnowledge/costEstimate.py b/modules/serviceCenter/services/serviceKnowledge/costEstimate.py
index c50da1fa..31b0035a 100644
--- a/modules/serviceCenter/services/serviceKnowledge/costEstimate.py
+++ b/modules/serviceCenter/services/serviceKnowledge/costEstimate.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Indicative cost estimation for a RAG bootstrap run.
diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
index 095e97cc..d2c0830b 100644
--- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
+++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Knowledge service: 3-tier RAG with indexing, semantic search, and context building."""
diff --git a/modules/serviceCenter/services/serviceKnowledge/ragLimits.py b/modules/serviceCenter/services/serviceKnowledge/ragLimits.py
index de0a4886..c6c6b54a 100644
--- a/modules/serviceCenter/services/serviceKnowledge/ragLimits.py
+++ b/modules/serviceCenter/services/serviceKnowledge/ragLimits.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Centralized RAG bootstrap limits + DataSource-scoped resolution.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py
index 5fec915e..dca5b01a 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Connection-lifecycle consumer bridging OAuth events to ingestion jobs.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py
index edddb2c1..8cb3d74a 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp bootstrap for the unified knowledge ingestion lane.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py
index 7c485a82..ae5edb8f 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Google Drive bootstrap for the unified knowledge ingestion lane.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py
index b07f83c3..8e2f5935 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Gmail bootstrap for the unified knowledge ingestion lane.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py
index 5dd3174c..f264ea2b 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""kDrive bootstrap for the unified knowledge ingestion lane.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py
index eb131350..53b688ee 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Outlook bootstrap for the unified knowledge ingestion lane.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py
index adb4b841..6eda9d20 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""SharePoint bootstrap for the unified knowledge ingestion lane.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py
index f1cd3887..1c4b838e 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Feature-data RAG bootstrap: indexes FeatureDataSource rows into the knowledge store.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subPreScan.py b/modules/serviceCenter/services/serviceKnowledge/subPreScan.py
index 0688deb2..d798b78d 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subPreScan.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subPreScan.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Structure Pre-Scan: fast, AI-free document analysis.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subTextClean.py b/modules/serviceCenter/services/serviceKnowledge/subTextClean.py
index 2d352cfa..cea45082 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subTextClean.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subTextClean.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Text normalisation utilities used by knowledge ingestion.
diff --git a/modules/serviceCenter/services/serviceKnowledge/subWalkerHelpers.py b/modules/serviceCenter/services/serviceKnowledge/subWalkerHelpers.py
index 41d9d458..51ef0161 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subWalkerHelpers.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subWalkerHelpers.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Shared helpers for ingestion walkers (timeouts, per-item logging).
diff --git a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
index 879983dd..00f07bfb 100644
--- a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
+++ b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Polymorphic UdbNode hierarchy for the Unified Data Bar.
diff --git a/modules/serviceCenter/services/serviceMessaging/__init__.py b/modules/serviceCenter/services/serviceMessaging/__init__.py
index 83b4dfa8..700bf73a 100644
--- a/modules/serviceCenter/services/serviceMessaging/__init__.py
+++ b/modules/serviceCenter/services/serviceMessaging/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Messaging service for the service center."""
diff --git a/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py b/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py
index cc43ca0c..c6ca8a0a 100644
--- a/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py
+++ b/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Messaging service for sending messages across different channels.
diff --git a/modules/serviceCenter/services/serviceMessaging/subscriptions/__init__.py b/modules/serviceCenter/services/serviceMessaging/subscriptions/__init__.py
index 1a631412..aada1c7c 100644
--- a/modules/serviceCenter/services/serviceMessaging/subscriptions/__init__.py
+++ b/modules/serviceCenter/services/serviceMessaging/subscriptions/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Subscription functions for the messaging service."""
diff --git a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionSystemErrors.py b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionSystemErrors.py
index 447bf076..4a5d8366 100644
--- a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionSystemErrors.py
+++ b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionSystemErrors.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Example subscription function for System Errors.
diff --git a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py
index b1cfecd0..729fe483 100644
--- a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py
+++ b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Subscription handler for WorkflowAutomation workflow run failures.
diff --git a/modules/serviceCenter/services/serviceSharepoint/__init__.py b/modules/serviceCenter/services/serviceSharepoint/__init__.py
index d1ce925c..befa27e5 100644
--- a/modules/serviceCenter/services/serviceSharepoint/__init__.py
+++ b/modules/serviceCenter/services/serviceSharepoint/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""SharePoint service."""
diff --git a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py
index e6cbc8e4..d31cfdda 100644
--- a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py
+++ b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Connector for SharePoint operations using Microsoft Graph API."""
diff --git a/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py b/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py
index 9db20b0f..4cf85b66 100644
--- a/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py
+++ b/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Enterprise subscription auto-renewal scheduler.
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index e5924aaf..439d9a5b 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Subscription Service — state-machine-based lifecycle management.
diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
index 73fbfb02..37d6a4df 100644
--- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
+++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Auto-provision Stripe Products and Prices from the built-in plan catalog.
diff --git a/modules/serviceCenter/services/serviceTicket/__init__.py b/modules/serviceCenter/services/serviceTicket/__init__.py
index b2403cc9..83bab1db 100644
--- a/modules/serviceCenter/services/serviceTicket/__init__.py
+++ b/modules/serviceCenter/services/serviceTicket/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Ticket service."""
diff --git a/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py b/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py
index ea229940..af797cb7 100644
--- a/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py
+++ b/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Ticket service for creating ticket interfaces."""
diff --git a/modules/serviceCenter/services/serviceWeb/__init__.py b/modules/serviceCenter/services/serviceWeb/__init__.py
index a4085312..38929add 100644
--- a/modules/serviceCenter/services/serviceWeb/__init__.py
+++ b/modules/serviceCenter/services/serviceWeb/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Web research service."""
diff --git a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py
index c6403c8d..a54b50b8 100644
--- a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py
+++ b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Web crawl service for handling web research operations.
diff --git a/modules/shared/__init__.py b/modules/shared/__init__.py
index 6d67ce5c..f520f36c 100644
--- a/modules/shared/__init__.py
+++ b/modules/shared/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Shared utilities module.
diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py
index 35d94d2e..7c17ab1c 100644
--- a/modules/shared/attributeUtils.py
+++ b/modules/shared/attributeUtils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Shared utilities for model attributes and labels.
diff --git a/modules/shared/callbackRegistry.py b/modules/shared/callbackRegistry.py
index 361f4e1d..4e972fa3 100644
--- a/modules/shared/callbackRegistry.py
+++ b/modules/shared/callbackRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Callback registry for decoupled event notifications.
diff --git a/modules/shared/configuration.py b/modules/shared/configuration.py
index fc7578f2..f7909533 100644
--- a/modules/shared/configuration.py
+++ b/modules/shared/configuration.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Utility module for configuration management.
@@ -586,4 +586,4 @@ def clearDecryptionCache() -> None:
_decryption_cache.clear()
# Create the global APP_CONFIG instance
-APP_CONFIG = Configuration()
\ No newline at end of file
+APP_CONFIG = Configuration()
diff --git a/modules/shared/dateRange.py b/modules/shared/dateRange.py
index 54a7c594..4a0ea3fa 100644
--- a/modules/shared/dateRange.py
+++ b/modules/shared/dateRange.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Date-range parsing for API endpoints that accept user-provided
diff --git a/modules/shared/debugLogger.py b/modules/shared/debugLogger.py
index 9062ed53..210a9251 100644
--- a/modules/shared/debugLogger.py
+++ b/modules/shared/debugLogger.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Simple debug logger for AI prompts and responses.
diff --git a/modules/shared/documentUtils.py b/modules/shared/documentUtils.py
index 37eec8a5..c9e5c53b 100644
--- a/modules/shared/documentUtils.py
+++ b/modules/shared/documentUtils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Document utility functions (Layer L0 - shared).
diff --git a/modules/shared/eventManagement.py b/modules/shared/eventManagement.py
index 1edd53be..728b7a38 100644
--- a/modules/shared/eventManagement.py
+++ b/modules/shared/eventManagement.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import asyncio
import logging
diff --git a/modules/shared/eventManager.py b/modules/shared/eventManager.py
index 13b0b322..9b49edba 100644
--- a/modules/shared/eventManager.py
+++ b/modules/shared/eventManager.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Event manager for SSE streaming (Layer L0 - shared).
diff --git a/modules/shared/featureDiscovery.py b/modules/shared/featureDiscovery.py
index 0332e9c1..a5eabcb8 100644
--- a/modules/shared/featureDiscovery.py
+++ b/modules/shared/featureDiscovery.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Feature discovery utility (Layer L0 - shared).
diff --git a/modules/shared/frontendTypes.py b/modules/shared/frontendTypes.py
index 46b142a1..5740ca19 100644
--- a/modules/shared/frontendTypes.py
+++ b/modules/shared/frontendTypes.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Global frontend type definitions for UI rendering.
diff --git a/modules/shared/httpResilience.py b/modules/shared/httpResilience.py
index 504686c8..f7226b7d 100644
--- a/modules/shared/httpResilience.py
+++ b/modules/shared/httpResilience.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Shared HTTP resilience helpers for provider connectors.
diff --git a/modules/shared/i18nRegistry.py b/modules/shared/i18nRegistry.py
index a72dcd9c..af44e2a9 100644
--- a/modules/shared/i18nRegistry.py
+++ b/modules/shared/i18nRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Gateway i18n registry: t(), @i18nModel, runtime translation cache.
diff --git a/modules/shared/jsonUtils.py b/modules/shared/jsonUtils.py
index ea3c0200..3770cf09 100644
--- a/modules/shared/jsonUtils.py
+++ b/modules/shared/jsonUtils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import json
import logging
diff --git a/modules/shared/mandateNameUtils.py b/modules/shared/mandateNameUtils.py
index 661aaeee..c1f9795f 100644
--- a/modules/shared/mandateNameUtils.py
+++ b/modules/shared/mandateNameUtils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Slug and validation helpers for Mandate.name (Kurzzeichen).
diff --git a/modules/shared/progressLogger.py b/modules/shared/progressLogger.py
index dbcb569a..1dfd7b22 100644
--- a/modules/shared/progressLogger.py
+++ b/modules/shared/progressLogger.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Progress Logger utility for standardized progress reporting in workflows.
diff --git a/modules/shared/stripeClient.py b/modules/shared/stripeClient.py
index 3f7dd3a7..68c9549f 100644
--- a/modules/shared/stripeClient.py
+++ b/modules/shared/stripeClient.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Central Stripe SDK initialization.
diff --git a/modules/shared/systemComponentRegistry.py b/modules/shared/systemComponentRegistry.py
index 70cd485d..9594280d 100644
--- a/modules/shared/systemComponentRegistry.py
+++ b/modules/shared/systemComponentRegistry.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
System-component lifecycle-hook registry (Layer L0 — shared).
diff --git a/modules/shared/timeUtils.py b/modules/shared/timeUtils.py
index 0c7b04f1..32a574f5 100644
--- a/modules/shared/timeUtils.py
+++ b/modules/shared/timeUtils.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Timezone utilities for consistent timestamp handling across the gateway.
@@ -187,4 +187,4 @@ def parseTimestamp(value: Any, default: Optional[float] = None) -> Optional[floa
return float(value)
except (ValueError, TypeError) as e:
logger.warning(f"parseTimestamp: Failed to convert value of type {type(value).__name__} '{value}' to float: {type(e).__name__}: {str(e)}, returning default={default}")
- return default
\ No newline at end of file
+ return default
diff --git a/modules/shared/voiceCatalog.py b/modules/shared/voiceCatalog.py
index 2e98902e..8a371a9e 100644
--- a/modules/shared/voiceCatalog.py
+++ b/modules/shared/voiceCatalog.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Voice / Language Catalog — Single Source of Truth.
diff --git a/modules/shared/workflowArtifactVisibility.py b/modules/shared/workflowArtifactVisibility.py
index 3431bee2..d2041aaf 100644
--- a/modules/shared/workflowArtifactVisibility.py
+++ b/modules/shared/workflowArtifactVisibility.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Heuristics for hiding internal workflow artefacts from user-facing file lists."""
from __future__ import annotations
diff --git a/modules/shared/workflowState.py b/modules/shared/workflowState.py
index 6a8680a3..ed32b6ab 100644
--- a/modules/shared/workflowState.py
+++ b/modules/shared/workflowState.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Workflow State
diff --git a/modules/system/__init__.py b/modules/system/__init__.py
index 7c14ddfa..e1b5c340 100644
--- a/modules/system/__init__.py
+++ b/modules/system/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
System Module - Contains system-level infrastructure:
diff --git a/modules/system/databaseHealth.py b/modules/system/databaseHealth.py
index 111cc592..5a9ec8fd 100644
--- a/modules/system/databaseHealth.py
+++ b/modules/system/databaseHealth.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Database health utilities — table statistics and orphan detection/cleanup.
diff --git a/modules/system/databaseMigration.py b/modules/system/databaseMigration.py
index 4227529e..a05f8f94 100644
--- a/modules/system/databaseMigration.py
+++ b/modules/system/databaseMigration.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Database migration utilities — backup (export) and restore (import) for all
diff --git a/modules/system/gdprDeletion.py b/modules/system/gdprDeletion.py
index ab3a6e2b..8b85a196 100644
--- a/modules/system/gdprDeletion.py
+++ b/modules/system/gdprDeletion.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Generic GDPR data deletion engine.
diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py
index 820376b1..3a96c0b1 100644
--- a/modules/system/i18nBootSync.py
+++ b/modules/system/i18nBootSync.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
i18n boot-time logic: label discovery, DB sync, and cache loading.
diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py
index b85ccf0b..0d8d01d9 100644
--- a/modules/system/mainSystem.py
+++ b/modules/system/mainSystem.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
System Module - Main Module.
diff --git a/modules/system/notifyMandateAdmins.py b/modules/system/notifyMandateAdmins.py
index 25acd9a7..90ce32bf 100644
--- a/modules/system/notifyMandateAdmins.py
+++ b/modules/system/notifyMandateAdmins.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Central notification utility for mandate administrators.
diff --git a/modules/system/registry.py b/modules/system/registry.py
index 1e2dffb4..61856356 100644
--- a/modules/system/registry.py
+++ b/modules/system/registry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Feature Registry for Plug&Play Feature Container Loading.
diff --git a/modules/workflowAutomation/agentTools.py b/modules/workflowAutomation/agentTools.py
index 88ac3a05..b8b16a29 100644
--- a/modules/workflowAutomation/agentTools.py
+++ b/modules/workflowAutomation/agentTools.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Workflow Toolbox - AI-assisted graph manipulation tools for WorkflowAutomation.
diff --git a/modules/workflowAutomation/editor/_valueKindResolver.py b/modules/workflowAutomation/editor/_valueKindResolver.py
index 63dd849d..e182b58a 100644
--- a/modules/workflowAutomation/editor/_valueKindResolver.py
+++ b/modules/workflowAutomation/editor/_valueKindResolver.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Shared value-kind resolution helpers.
Extracted from conditionOperators so that upstreamPathsService can resolve
diff --git a/modules/workflowAutomation/editor/adapterValidator.py b/modules/workflowAutomation/editor/adapterValidator.py
index 6e430878..4ed5b1c8 100644
--- a/modules/workflowAutomation/editor/adapterValidator.py
+++ b/modules/workflowAutomation/editor/adapterValidator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Adapter Validator — enforces 5 drift rules between Schicht-3 NodeAdapters
diff --git a/modules/workflowAutomation/editor/conditionOperators.py b/modules/workflowAutomation/editor/conditionOperators.py
index 5b5d611a..b442ed0d 100644
--- a/modules/workflowAutomation/editor/conditionOperators.py
+++ b/modules/workflowAutomation/editor/conditionOperators.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Backend-driven condition operator catalog and value-kind resolution for flow.ifElse."""
from __future__ import annotations
diff --git a/modules/workflowAutomation/editor/nodeRegistry.py b/modules/workflowAutomation/editor/nodeRegistry.py
index 7a7ca1a9..33d4ccaa 100644
--- a/modules/workflowAutomation/editor/nodeRegistry.py
+++ b/modules/workflowAutomation/editor/nodeRegistry.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Node Type Registry for WorkflowAutomation editor - static node definitions (start, input, flow, data, ai, email, …).
diff --git a/modules/workflowAutomation/editor/switchOutput.py b/modules/workflowAutomation/editor/switchOutput.py
index b70c5eb1..5e189e79 100644
--- a/modules/workflowAutomation/editor/switchOutput.py
+++ b/modules/workflowAutomation/editor/switchOutput.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Build flow.switch branch payloads: filtered context + loop-ready items."""
from __future__ import annotations
diff --git a/modules/workflowAutomation/editor/upstreamPathsService.py b/modules/workflowAutomation/editor/upstreamPathsService.py
index a98be149..1f270a96 100644
--- a/modules/workflowAutomation/editor/upstreamPathsService.py
+++ b/modules/workflowAutomation/editor/upstreamPathsService.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Compute pickable upstream paths for DataPicker / AI workflow tools."""
from __future__ import annotations
diff --git a/modules/workflowAutomation/engine/__init__.py b/modules/workflowAutomation/engine/__init__.py
index 0656ab39..0ea8cbbe 100644
--- a/modules/workflowAutomation/engine/__init__.py
+++ b/modules/workflowAutomation/engine/__init__.py
@@ -1,2 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# automation2 - n8n-style graph execution engine.
diff --git a/modules/workflowAutomation/engine/_runNotifications.py b/modules/workflowAutomation/engine/_runNotifications.py
index c8d7786d..6b09ea6c 100644
--- a/modules/workflowAutomation/engine/_runNotifications.py
+++ b/modules/workflowAutomation/engine/_runNotifications.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Run failure notification helpers.
Extracted from scheduler/mainScheduler to break the bidirectional import
diff --git a/modules/workflowAutomation/engine/clickupTaskUpdateMerge.py b/modules/workflowAutomation/engine/clickupTaskUpdateMerge.py
index a74cdaef..5ea56199 100644
--- a/modules/workflowAutomation/engine/clickupTaskUpdateMerge.py
+++ b/modules/workflowAutomation/engine/clickupTaskUpdateMerge.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Merge clickup.updateTask node parameter taskUpdateEntries into taskUpdate JSON.
import json
diff --git a/modules/workflowAutomation/engine/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py
index b1c877c2..e188adab 100644
--- a/modules/workflowAutomation/engine/executionEngine.py
+++ b/modules/workflowAutomation/engine/executionEngine.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Main execution engine for automation2 graphs.
import asyncio
diff --git a/modules/workflowAutomation/engine/executors/__init__.py b/modules/workflowAutomation/engine/executors/__init__.py
index 4d2180c3..d6a444df 100644
--- a/modules/workflowAutomation/engine/executors/__init__.py
+++ b/modules/workflowAutomation/engine/executors/__init__.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Executors for automation2 node types.
from .triggerExecutor import TriggerExecutor
diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
index 82c0cbe1..12dffc31 100644
--- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
+++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Action node executor — maps ai.*, email.*, sharepoint.*, clickup.*, file.*, trustee.* to method actions.
#
# Typed port system: parameters resolve via DataRefs / static values. Declarative port inheritance
diff --git a/modules/workflowAutomation/engine/executors/dataExecutor.py b/modules/workflowAutomation/engine/executors/dataExecutor.py
index e22eda6f..b2a53966 100644
--- a/modules/workflowAutomation/engine/executors/dataExecutor.py
+++ b/modules/workflowAutomation/engine/executors/dataExecutor.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Data manipulation node executor: data.aggregate, data.filter, data.consolidate.
import logging
diff --git a/modules/workflowAutomation/engine/executors/flowExecutor.py b/modules/workflowAutomation/engine/executors/flowExecutor.py
index 0f5f85d1..8029b4e3 100644
--- a/modules/workflowAutomation/engine/executors/flowExecutor.py
+++ b/modules/workflowAutomation/engine/executors/flowExecutor.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Flow control node executor (ifElse, switch, loop, merge).
import logging
diff --git a/modules/workflowAutomation/engine/executors/inputExecutor.py b/modules/workflowAutomation/engine/executors/inputExecutor.py
index 926dd3a8..364aca69 100644
--- a/modules/workflowAutomation/engine/executors/inputExecutor.py
+++ b/modules/workflowAutomation/engine/executors/inputExecutor.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Input/Human node executor - creates tasks and pauses execution.
import logging
diff --git a/modules/workflowAutomation/engine/executors/ioExecutor.py b/modules/workflowAutomation/engine/executors/ioExecutor.py
index ae527adf..805a0338 100644
--- a/modules/workflowAutomation/engine/executors/ioExecutor.py
+++ b/modules/workflowAutomation/engine/executors/ioExecutor.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# I/O node executor - delegates to ActionExecutor.
import logging
diff --git a/modules/workflowAutomation/engine/executors/triggerExecutor.py b/modules/workflowAutomation/engine/executors/triggerExecutor.py
index 35b46237..1ab8c6b2 100644
--- a/modules/workflowAutomation/engine/executors/triggerExecutor.py
+++ b/modules/workflowAutomation/engine/executors/triggerExecutor.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Start node executor (node type trigger.manual) — outputs the unified run envelope from context.
import logging
diff --git a/modules/workflowAutomation/engine/featureInstanceRefMigration.py b/modules/workflowAutomation/engine/featureInstanceRefMigration.py
index b4fba529..7edd84f9 100644
--- a/modules/workflowAutomation/engine/featureInstanceRefMigration.py
+++ b/modules/workflowAutomation/engine/featureInstanceRefMigration.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Phase-5 Schicht-4 migration: convert raw ``featureInstanceId: ""`` workflow
parameters into typed ``FeatureInstanceRef`` envelopes on disk.
diff --git a/modules/workflowAutomation/engine/graphUtils.py b/modules/workflowAutomation/engine/graphUtils.py
index 68368b48..cb67076c 100644
--- a/modules/workflowAutomation/engine/graphUtils.py
+++ b/modules/workflowAutomation/engine/graphUtils.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Graph parsing, validation, and topological sort for automation2.
import json
diff --git a/modules/workflowAutomation/engine/pickNotPushMigration.py b/modules/workflowAutomation/engine/pickNotPushMigration.py
index 14b91eae..6a841ec9 100644
--- a/modules/workflowAutomation/engine/pickNotPushMigration.py
+++ b/modules/workflowAutomation/engine/pickNotPushMigration.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Graph helpers for Pick-not-Push: materialize typed DataRefs before executeGraph runs.
diff --git a/modules/workflowAutomation/engine/runEnvelope.py b/modules/workflowAutomation/engine/runEnvelope.py
index 44da2fb5..cd13d46d 100644
--- a/modules/workflowAutomation/engine/runEnvelope.py
+++ b/modules/workflowAutomation/engine/runEnvelope.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Unified run envelope for Automation2 start/trigger nodes.
diff --git a/modules/workflowAutomation/engine/runFileLogger.py b/modules/workflowAutomation/engine/runFileLogger.py
index af57c275..5efdec98 100644
--- a/modules/workflowAutomation/engine/runFileLogger.py
+++ b/modules/workflowAutomation/engine/runFileLogger.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Per-run NDJSON logs for persisted workflow-automation runs."""
from __future__ import annotations
diff --git a/modules/workflowAutomation/engine/scheduleCron.py b/modules/workflowAutomation/engine/scheduleCron.py
index 4a0cfa43..f7f35c38 100644
--- a/modules/workflowAutomation/engine/scheduleCron.py
+++ b/modules/workflowAutomation/engine/scheduleCron.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Parse cron strings (5-field or 6-field) to APScheduler CronTrigger kwargs.
Frontend produces: "minute hour day month dow" (5-field) or "sec min hour day month dow" (6-field).
diff --git a/modules/workflowAutomation/engine/udmUpstreamShapes.py b/modules/workflowAutomation/engine/udmUpstreamShapes.py
index 33dea176..91c6bdd6 100644
--- a/modules/workflowAutomation/engine/udmUpstreamShapes.py
+++ b/modules/workflowAutomation/engine/udmUpstreamShapes.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Pure shape coercion for UDM-related upstream payloads (tests + optional tooling).
diff --git a/modules/workflowAutomation/helpers.py b/modules/workflowAutomation/helpers.py
index ddbde49e..b2ba3f9f 100644
--- a/modules/workflowAutomation/helpers.py
+++ b/modules/workflowAutomation/helpers.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Shared helpers for WorkflowAutomation route files.
diff --git a/modules/workflowAutomation/mainWorkflowAutomation.py b/modules/workflowAutomation/mainWorkflowAutomation.py
index a05064c9..086530c7 100644
--- a/modules/workflowAutomation/mainWorkflowAutomation.py
+++ b/modules/workflowAutomation/mainWorkflowAutomation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
WorkflowAutomation System Component — n8n-style flow automation.
@@ -150,17 +150,17 @@ def _migrateRbacNamespace() -> None:
totalUpdated = 0
for oldPrefix, newPrefix in _REPLACEMENTS:
cur.execute(
- 'SELECT id, "objectKey" FROM "AccessRule" WHERE "objectKey" LIKE %s',
+ 'SELECT id, "item" FROM "AccessRule" WHERE "item" LIKE %s',
(f"{oldPrefix}%",),
)
rows = cur.fetchall()
if not rows:
continue
- for rowId, objectKey in rows:
- newKey = objectKey.replace(oldPrefix, newPrefix, 1)
+ for rowId, item in rows:
+ newKey = item.replace(oldPrefix, newPrefix, 1)
cur.execute(
- 'UPDATE "AccessRule" SET "objectKey" = %s WHERE id = %s',
+ 'UPDATE "AccessRule" SET "item" = %s WHERE id = %s',
(newKey, rowId),
)
totalUpdated += 1
diff --git a/modules/workflowAutomation/scheduler/__init__.py b/modules/workflowAutomation/scheduler/__init__.py
index ab966ca5..0a344346 100644
--- a/modules/workflowAutomation/scheduler/__init__.py
+++ b/modules/workflowAutomation/scheduler/__init__.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Workflow Scheduler — consolidated scheduler with v1 incremental sync patterns.
from modules.workflowAutomation.scheduler.mainScheduler import (
WorkflowScheduler,
diff --git a/modules/workflowAutomation/scheduler/emailPoller.py b/modules/workflowAutomation/scheduler/emailPoller.py
index 944135bc..ae5df741 100644
--- a/modules/workflowAutomation/scheduler/emailPoller.py
+++ b/modules/workflowAutomation/scheduler/emailPoller.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Background email poller for automation2.
diff --git a/modules/workflowAutomation/scheduler/mainScheduler.py b/modules/workflowAutomation/scheduler/mainScheduler.py
index a0ced9cc..d264d25d 100644
--- a/modules/workflowAutomation/scheduler/mainScheduler.py
+++ b/modules/workflowAutomation/scheduler/mainScheduler.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Consolidated Workflow Scheduler.
diff --git a/modules/workflows/methods/_actionSignatureValidator.py b/modules/workflows/methods/_actionSignatureValidator.py
index ce43ee7b..b3d830a5 100644
--- a/modules/workflows/methods/_actionSignatureValidator.py
+++ b/modules/workflows/methods/_actionSignatureValidator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Action signature validator for the Typed Action Architecture (Phase 2).
diff --git a/modules/workflows/methods/methodAi/__init__.py b/modules/workflows/methods/methodAi/__init__.py
index 7ce40281..e86dc94f 100644
--- a/modules/workflows/methods/methodAi/__init__.py
+++ b/modules/workflows/methods/methodAi/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from .methodAi import MethodAi
diff --git a/modules/workflows/methods/methodAi/_common.py b/modules/workflows/methods/methodAi/_common.py
index 27b36663..2ebc8f18 100644
--- a/modules/workflows/methods/methodAi/_common.py
+++ b/modules/workflows/methods/methodAi/_common.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Shared helpers for AI workflow actions."""
diff --git a/modules/workflows/methods/methodAi/actions/__init__.py b/modules/workflows/methods/methodAi/actions/__init__.py
index 641b4eaf..f64007f4 100644
--- a/modules/workflows/methods/methodAi/actions/__init__.py
+++ b/modules/workflows/methods/methodAi/actions/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Action modules for AI operations."""
diff --git a/modules/workflows/methods/methodAi/actions/consolidate.py b/modules/workflows/methods/methodAi/actions/consolidate.py
index 70d345cd..03854c38 100644
--- a/modules/workflows/methods/methodAi/actions/consolidate.py
+++ b/modules/workflows/methods/methodAi/actions/consolidate.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import json
diff --git a/modules/workflows/methods/methodAi/actions/convertDocument.py b/modules/workflows/methods/methodAi/actions/convertDocument.py
index b2ed908b..e318e83d 100644
--- a/modules/workflows/methods/methodAi/actions/convertDocument.py
+++ b/modules/workflows/methods/methodAi/actions/convertDocument.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodAi/actions/generateCode.py b/modules/workflows/methods/methodAi/actions/generateCode.py
index 7a13e4a1..cf0d816e 100644
--- a/modules/workflows/methods/methodAi/actions/generateCode.py
+++ b/modules/workflows/methods/methodAi/actions/generateCode.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py
index 20b82042..a4f37edc 100644
--- a/modules/workflows/methods/methodAi/actions/generateDocument.py
+++ b/modules/workflows/methods/methodAi/actions/generateDocument.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py
index e3cc10f0..77adc40f 100644
--- a/modules/workflows/methods/methodAi/actions/process.py
+++ b/modules/workflows/methods/methodAi/actions/process.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import base64
diff --git a/modules/workflows/methods/methodAi/actions/summarizeDocument.py b/modules/workflows/methods/methodAi/actions/summarizeDocument.py
index 4c2bb2bc..c7950e2a 100644
--- a/modules/workflows/methods/methodAi/actions/summarizeDocument.py
+++ b/modules/workflows/methods/methodAi/actions/summarizeDocument.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodAi/actions/translateDocument.py b/modules/workflows/methods/methodAi/actions/translateDocument.py
index dc0533a9..28b4074f 100644
--- a/modules/workflows/methods/methodAi/actions/translateDocument.py
+++ b/modules/workflows/methods/methodAi/actions/translateDocument.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py
index b020cff4..6e82a92f 100644
--- a/modules/workflows/methods/methodAi/actions/webResearch.py
+++ b/modules/workflows/methods/methodAi/actions/webResearch.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodAi/helpers/__init__.py b/modules/workflows/methods/methodAi/helpers/__init__.py
index 4833e0e7..445ed4b1 100644
--- a/modules/workflows/methods/methodAi/helpers/__init__.py
+++ b/modules/workflows/methods/methodAi/helpers/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Helper modules for AI method operations."""
diff --git a/modules/workflows/methods/methodAi/helpers/csvProcessing.py b/modules/workflows/methods/methodAi/helpers/csvProcessing.py
index 9121f43c..58d2a6f1 100644
--- a/modules/workflows/methods/methodAi/helpers/csvProcessing.py
+++ b/modules/workflows/methods/methodAi/helpers/csvProcessing.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodAi/methodAi.py b/modules/workflows/methods/methodAi/methodAi.py
index 55c9a40a..4dc021ed 100644
--- a/modules/workflows/methods/methodAi/methodAi.py
+++ b/modules/workflows/methods/methodAi/methodAi.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py
index d9a941c5..55f15c36 100644
--- a/modules/workflows/methods/methodBase.py
+++ b/modules/workflows/methods/methodBase.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import re
@@ -496,4 +496,4 @@ class MethodBase:
self.logger.warning(f"Error generating meaningful file name, using fallback: {str(e)}")
# Fallback to timestamp-based naming
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
- return f"{base_name}_{timestamp}.{extension}"
\ No newline at end of file
+ return f"{base_name}_{timestamp}.{extension}"
diff --git a/modules/workflows/methods/methodClickup/__init__.py b/modules/workflows/methods/methodClickup/__init__.py
index 9e0362c4..84961b1c 100644
--- a/modules/workflows/methods/methodClickup/__init__.py
+++ b/modules/workflows/methods/methodClickup/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from .methodClickup import MethodClickup
diff --git a/modules/workflows/methods/methodClickup/actions/__init__.py b/modules/workflows/methods/methodClickup/actions/__init__.py
index 5c54c5df..67956702 100644
--- a/modules/workflows/methods/methodClickup/actions/__init__.py
+++ b/modules/workflows/methods/methodClickup/actions/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp workflow actions."""
diff --git a/modules/workflows/methods/methodClickup/actions/create_task.py b/modules/workflows/methods/methodClickup/actions/create_task.py
index d010c234..1665aabe 100644
--- a/modules/workflows/methods/methodClickup/actions/create_task.py
+++ b/modules/workflows/methods/methodClickup/actions/create_task.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import json
diff --git a/modules/workflows/methods/methodClickup/actions/get_task.py b/modules/workflows/methods/methodClickup/actions/get_task.py
index 1e3eecad..9eae5569 100644
--- a/modules/workflows/methods/methodClickup/actions/get_task.py
+++ b/modules/workflows/methods/methodClickup/actions/get_task.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import json
diff --git a/modules/workflows/methods/methodClickup/actions/list_fields.py b/modules/workflows/methods/methodClickup/actions/list_fields.py
index 851437d7..a33868db 100644
--- a/modules/workflows/methods/methodClickup/actions/list_fields.py
+++ b/modules/workflows/methods/methodClickup/actions/list_fields.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import json
diff --git a/modules/workflows/methods/methodClickup/actions/list_tasks.py b/modules/workflows/methods/methodClickup/actions/list_tasks.py
index 9ae57f94..90c5694f 100644
--- a/modules/workflows/methods/methodClickup/actions/list_tasks.py
+++ b/modules/workflows/methods/methodClickup/actions/list_tasks.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import json
diff --git a/modules/workflows/methods/methodClickup/actions/search_tasks.py b/modules/workflows/methods/methodClickup/actions/search_tasks.py
index b173020c..259b0a7a 100644
--- a/modules/workflows/methods/methodClickup/actions/search_tasks.py
+++ b/modules/workflows/methods/methodClickup/actions/search_tasks.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import json
diff --git a/modules/workflows/methods/methodClickup/actions/update_task.py b/modules/workflows/methods/methodClickup/actions/update_task.py
index 6282ec78..16b2173c 100644
--- a/modules/workflows/methods/methodClickup/actions/update_task.py
+++ b/modules/workflows/methods/methodClickup/actions/update_task.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import json
diff --git a/modules/workflows/methods/methodClickup/actions/upload_attachment.py b/modules/workflows/methods/methodClickup/actions/upload_attachment.py
index 8cd1de4d..8691ab48 100644
--- a/modules/workflows/methods/methodClickup/actions/upload_attachment.py
+++ b/modules/workflows/methods/methodClickup/actions/upload_attachment.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import base64
diff --git a/modules/workflows/methods/methodClickup/helpers/__init__.py b/modules/workflows/methods/methodClickup/helpers/__init__.py
index fdcc4f0e..06003961 100644
--- a/modules/workflows/methods/methodClickup/helpers/__init__.py
+++ b/modules/workflows/methods/methodClickup/helpers/__init__.py
@@ -1,2 +1,2 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
diff --git a/modules/workflows/methods/methodClickup/helpers/connection.py b/modules/workflows/methods/methodClickup/helpers/connection.py
index cdcd3601..4e072ea5 100644
--- a/modules/workflows/methods/methodClickup/helpers/connection.py
+++ b/modules/workflows/methods/methodClickup/helpers/connection.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Resolve ClickUp UserConnection and configure ClickupService."""
diff --git a/modules/workflows/methods/methodClickup/helpers/pathparse.py b/modules/workflows/methods/methodClickup/helpers/pathparse.py
index c97b69b2..e5485920 100644
--- a/modules/workflows/methods/methodClickup/helpers/pathparse.py
+++ b/modules/workflows/methods/methodClickup/helpers/pathparse.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Parse virtual ClickUp paths used by the connector."""
diff --git a/modules/workflows/methods/methodClickup/methodClickup.py b/modules/workflows/methods/methodClickup/methodClickup.py
index 725929dd..ed03899c 100644
--- a/modules/workflows/methods/methodClickup/methodClickup.py
+++ b/modules/workflows/methods/methodClickup/methodClickup.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp workflow method — list/search/get/create/update tasks and upload attachments."""
diff --git a/modules/workflows/methods/methodContext/__init__.py b/modules/workflows/methods/methodContext/__init__.py
index 8d6c7823..6359aebd 100644
--- a/modules/workflows/methods/methodContext/__init__.py
+++ b/modules/workflows/methods/methodContext/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from .methodContext import MethodContext
diff --git a/modules/workflows/methods/methodContext/actions/__init__.py b/modules/workflows/methods/methodContext/actions/__init__.py
index 1750882e..4a18fd19 100644
--- a/modules/workflows/methods/methodContext/actions/__init__.py
+++ b/modules/workflows/methods/methodContext/actions/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Action modules for Context operations."""
diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py
index e1869be3..5172ced2 100644
--- a/modules/workflows/methods/methodContext/actions/extractContent.py
+++ b/modules/workflows/methods/methodContext/actions/extractContent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""context.extractContent — extracts content without AI.
diff --git a/modules/workflows/methods/methodContext/actions/filterContext.py b/modules/workflows/methods/methodContext/actions/filterContext.py
index 6087b380..0ee02da4 100644
--- a/modules/workflows/methods/methodContext/actions/filterContext.py
+++ b/modules/workflows/methods/methodContext/actions/filterContext.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Action ``context.filterContext``.
diff --git a/modules/workflows/methods/methodContext/actions/getDocumentIndex.py b/modules/workflows/methods/methodContext/actions/getDocumentIndex.py
index b2822e0d..27029fed 100644
--- a/modules/workflows/methods/methodContext/actions/getDocumentIndex.py
+++ b/modules/workflows/methods/methodContext/actions/getDocumentIndex.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodContext/actions/mergeContext.py b/modules/workflows/methods/methodContext/actions/mergeContext.py
index 79582cf2..0cd3485c 100644
--- a/modules/workflows/methods/methodContext/actions/mergeContext.py
+++ b/modules/workflows/methods/methodContext/actions/mergeContext.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Action ``context.mergeContext``.
diff --git a/modules/workflows/methods/methodContext/actions/neutralizeData.py b/modules/workflows/methods/methodContext/actions/neutralizeData.py
index 5bd1eb34..89298f6a 100644
--- a/modules/workflows/methods/methodContext/actions/neutralizeData.py
+++ b/modules/workflows/methods/methodContext/actions/neutralizeData.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import base64
diff --git a/modules/workflows/methods/methodContext/actions/setContext.py b/modules/workflows/methods/methodContext/actions/setContext.py
index 58925f9e..861df789 100644
--- a/modules/workflows/methods/methodContext/actions/setContext.py
+++ b/modules/workflows/methods/methodContext/actions/setContext.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Action ``context.setContext``.
diff --git a/modules/workflows/methods/methodContext/actions/transformContext.py b/modules/workflows/methods/methodContext/actions/transformContext.py
index ffff183d..5288dbae 100644
--- a/modules/workflows/methods/methodContext/actions/transformContext.py
+++ b/modules/workflows/methods/methodContext/actions/transformContext.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Action ``context.transformContext``.
diff --git a/modules/workflows/methods/methodContext/actions/triggerPreprocessingServer.py b/modules/workflows/methods/methodContext/actions/triggerPreprocessingServer.py
index 2f011a25..e17df19b 100644
--- a/modules/workflows/methods/methodContext/actions/triggerPreprocessingServer.py
+++ b/modules/workflows/methods/methodContext/actions/triggerPreprocessingServer.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodContext/contextEnvelope.py b/modules/workflows/methods/methodContext/contextEnvelope.py
index c35836cf..fad01ec0 100644
--- a/modules/workflows/methods/methodContext/contextEnvelope.py
+++ b/modules/workflows/methods/methodContext/contextEnvelope.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Versioned ``ActionResult.data`` envelope for context.* actions (merge, transform)."""
from __future__ import annotations
diff --git a/modules/workflows/methods/methodContext/helpers/__init__.py b/modules/workflows/methods/methodContext/helpers/__init__.py
index e1e2ab56..27e16884 100644
--- a/modules/workflows/methods/methodContext/helpers/__init__.py
+++ b/modules/workflows/methods/methodContext/helpers/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Helper modules for Context method operations."""
diff --git a/modules/workflows/methods/methodContext/helpers/documentIndex.py b/modules/workflows/methods/methodContext/helpers/documentIndex.py
index bba349cf..73bd65a9 100644
--- a/modules/workflows/methods/methodContext/helpers/documentIndex.py
+++ b/modules/workflows/methods/methodContext/helpers/documentIndex.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodContext/helpers/formatting.py b/modules/workflows/methods/methodContext/helpers/formatting.py
index ac38fb86..79ecace2 100644
--- a/modules/workflows/methods/methodContext/helpers/formatting.py
+++ b/modules/workflows/methods/methodContext/helpers/formatting.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodContext/methodContext.py b/modules/workflows/methods/methodContext/methodContext.py
index 80e0c089..7d9bf215 100644
--- a/modules/workflows/methods/methodContext/methodContext.py
+++ b/modules/workflows/methods/methodContext/methodContext.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodFile/__init__.py b/modules/workflows/methods/methodFile/__init__.py
index b8c41e0f..45b4a956 100644
--- a/modules/workflows/methods/methodFile/__init__.py
+++ b/modules/workflows/methods/methodFile/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from .methodFile import MethodFile
diff --git a/modules/workflows/methods/methodFile/actions/__init__.py b/modules/workflows/methods/methodFile/actions/__init__.py
index 9aef4028..5ffbb1f7 100644
--- a/modules/workflows/methods/methodFile/actions/__init__.py
+++ b/modules/workflows/methods/methodFile/actions/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from .create import create
diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py
index 973f62d0..e7acd3bd 100644
--- a/modules/workflows/methods/methodFile/actions/create.py
+++ b/modules/workflows/methods/methodFile/actions/create.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List, Optional
diff --git a/modules/workflows/methods/methodFile/methodFile.py b/modules/workflows/methods/methodFile/methodFile.py
index c30f86a4..a1d48f87 100644
--- a/modules/workflows/methods/methodFile/methodFile.py
+++ b/modules/workflows/methods/methodFile/methodFile.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodJira/__init__.py b/modules/workflows/methods/methodJira/__init__.py
index e8b3822d..8793570d 100644
--- a/modules/workflows/methods/methodJira/__init__.py
+++ b/modules/workflows/methods/methodJira/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from .methodJira import MethodJira
diff --git a/modules/workflows/methods/methodJira/actions/__init__.py b/modules/workflows/methods/methodJira/actions/__init__.py
index 67b0d38d..8b2cb166 100644
--- a/modules/workflows/methods/methodJira/actions/__init__.py
+++ b/modules/workflows/methods/methodJira/actions/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Action modules for JIRA operations."""
diff --git a/modules/workflows/methods/methodJira/actions/connectJira.py b/modules/workflows/methods/methodJira/actions/connectJira.py
index 45b60cad..737ccd88 100644
--- a/modules/workflows/methods/methodJira/actions/connectJira.py
+++ b/modules/workflows/methods/methodJira/actions/connectJira.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodJira/actions/createCsvContent.py b/modules/workflows/methods/methodJira/actions/createCsvContent.py
index cbec7960..46ec9965 100644
--- a/modules/workflows/methods/methodJira/actions/createCsvContent.py
+++ b/modules/workflows/methods/methodJira/actions/createCsvContent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodJira/actions/createExcelContent.py b/modules/workflows/methods/methodJira/actions/createExcelContent.py
index 631795b3..7a71a95d 100644
--- a/modules/workflows/methods/methodJira/actions/createExcelContent.py
+++ b/modules/workflows/methods/methodJira/actions/createExcelContent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodJira/actions/exportTicketsAsJson.py b/modules/workflows/methods/methodJira/actions/exportTicketsAsJson.py
index 55d99654..432457cd 100644
--- a/modules/workflows/methods/methodJira/actions/exportTicketsAsJson.py
+++ b/modules/workflows/methods/methodJira/actions/exportTicketsAsJson.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodJira/actions/importTicketsFromJson.py b/modules/workflows/methods/methodJira/actions/importTicketsFromJson.py
index b997889e..a6b06652 100644
--- a/modules/workflows/methods/methodJira/actions/importTicketsFromJson.py
+++ b/modules/workflows/methods/methodJira/actions/importTicketsFromJson.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodJira/actions/mergeTicketData.py b/modules/workflows/methods/methodJira/actions/mergeTicketData.py
index 2bd7ab74..c447ff6a 100644
--- a/modules/workflows/methods/methodJira/actions/mergeTicketData.py
+++ b/modules/workflows/methods/methodJira/actions/mergeTicketData.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodJira/actions/parseCsvContent.py b/modules/workflows/methods/methodJira/actions/parseCsvContent.py
index bbdc2cc7..91d7b25e 100644
--- a/modules/workflows/methods/methodJira/actions/parseCsvContent.py
+++ b/modules/workflows/methods/methodJira/actions/parseCsvContent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodJira/actions/parseExcelContent.py b/modules/workflows/methods/methodJira/actions/parseExcelContent.py
index 5ac4e548..4a2ec1b7 100644
--- a/modules/workflows/methods/methodJira/actions/parseExcelContent.py
+++ b/modules/workflows/methods/methodJira/actions/parseExcelContent.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodJira/helpers/__init__.py b/modules/workflows/methods/methodJira/helpers/__init__.py
index cf2fc4c7..27c3e974 100644
--- a/modules/workflows/methods/methodJira/helpers/__init__.py
+++ b/modules/workflows/methods/methodJira/helpers/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Helper modules for JIRA method operations."""
diff --git a/modules/workflows/methods/methodJira/helpers/adfConverter.py b/modules/workflows/methods/methodJira/helpers/adfConverter.py
index d8619989..506f3d1c 100644
--- a/modules/workflows/methods/methodJira/helpers/adfConverter.py
+++ b/modules/workflows/methods/methodJira/helpers/adfConverter.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodJira/helpers/documentParsing.py b/modules/workflows/methods/methodJira/helpers/documentParsing.py
index b0608524..1bb795ab 100644
--- a/modules/workflows/methods/methodJira/helpers/documentParsing.py
+++ b/modules/workflows/methods/methodJira/helpers/documentParsing.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodJira/methodJira.py b/modules/workflows/methods/methodJira/methodJira.py
index 0268d020..0fa4ddfd 100644
--- a/modules/workflows/methods/methodJira/methodJira.py
+++ b/modules/workflows/methods/methodJira/methodJira.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodOutlook/__init__.py b/modules/workflows/methods/methodOutlook/__init__.py
index c7653010..02187a5d 100644
--- a/modules/workflows/methods/methodOutlook/__init__.py
+++ b/modules/workflows/methods/methodOutlook/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from .methodOutlook import MethodOutlook
diff --git a/modules/workflows/methods/methodOutlook/actions/__init__.py b/modules/workflows/methods/methodOutlook/actions/__init__.py
index f62e3e0a..c6976650 100644
--- a/modules/workflows/methods/methodOutlook/actions/__init__.py
+++ b/modules/workflows/methods/methodOutlook/actions/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Action modules for Outlook operations."""
diff --git a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py
index 22c5ff62..6b482d42 100644
--- a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py
+++ b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodOutlook/actions/readEmails.py b/modules/workflows/methods/methodOutlook/actions/readEmails.py
index 5620a62d..c6051a4b 100644
--- a/modules/workflows/methods/methodOutlook/actions/readEmails.py
+++ b/modules/workflows/methods/methodOutlook/actions/readEmails.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodOutlook/actions/searchEmails.py b/modules/workflows/methods/methodOutlook/actions/searchEmails.py
index f12c6d71..5f4c8c85 100644
--- a/modules/workflows/methods/methodOutlook/actions/searchEmails.py
+++ b/modules/workflows/methods/methodOutlook/actions/searchEmails.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodOutlook/actions/sendDraftEmail.py b/modules/workflows/methods/methodOutlook/actions/sendDraftEmail.py
index 1c0c80d4..86749339 100644
--- a/modules/workflows/methods/methodOutlook/actions/sendDraftEmail.py
+++ b/modules/workflows/methods/methodOutlook/actions/sendDraftEmail.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodOutlook/helpers/__init__.py b/modules/workflows/methods/methodOutlook/helpers/__init__.py
index 45028b5a..42e65b10 100644
--- a/modules/workflows/methods/methodOutlook/helpers/__init__.py
+++ b/modules/workflows/methods/methodOutlook/helpers/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Helper modules for Outlook method operations."""
diff --git a/modules/workflows/methods/methodOutlook/helpers/connection.py b/modules/workflows/methods/methodOutlook/helpers/connection.py
index cd42b7f5..361ad4d9 100644
--- a/modules/workflows/methods/methodOutlook/helpers/connection.py
+++ b/modules/workflows/methods/methodOutlook/helpers/connection.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodOutlook/helpers/emailProcessing.py b/modules/workflows/methods/methodOutlook/helpers/emailProcessing.py
index d34bb778..90249ffe 100644
--- a/modules/workflows/methods/methodOutlook/helpers/emailProcessing.py
+++ b/modules/workflows/methods/methodOutlook/helpers/emailProcessing.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodOutlook/helpers/folderManagement.py b/modules/workflows/methods/methodOutlook/helpers/folderManagement.py
index 2bbb8195..3cc66161 100644
--- a/modules/workflows/methods/methodOutlook/helpers/folderManagement.py
+++ b/modules/workflows/methods/methodOutlook/helpers/folderManagement.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodOutlook/methodOutlook.py b/modules/workflows/methods/methodOutlook/methodOutlook.py
index 4370b237..a1cd2600 100644
--- a/modules/workflows/methods/methodOutlook/methodOutlook.py
+++ b/modules/workflows/methods/methodOutlook/methodOutlook.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodSharepoint/__init__.py b/modules/workflows/methods/methodSharepoint/__init__.py
index 40c14cf3..7ff61497 100644
--- a/modules/workflows/methods/methodSharepoint/__init__.py
+++ b/modules/workflows/methods/methodSharepoint/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from .methodSharepoint import MethodSharepoint
diff --git a/modules/workflows/methods/methodSharepoint/actions/__init__.py b/modules/workflows/methods/methodSharepoint/actions/__init__.py
index 6975f8af..85de2950 100644
--- a/modules/workflows/methods/methodSharepoint/actions/__init__.py
+++ b/modules/workflows/methods/methodSharepoint/actions/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Action modules for SharePoint operations."""
diff --git a/modules/workflows/methods/methodSharepoint/actions/analyzeFolderUsage.py b/modules/workflows/methods/methodSharepoint/actions/analyzeFolderUsage.py
index a4bf18b6..2768ce75 100644
--- a/modules/workflows/methods/methodSharepoint/actions/analyzeFolderUsage.py
+++ b/modules/workflows/methods/methodSharepoint/actions/analyzeFolderUsage.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodSharepoint/actions/copyFile.py b/modules/workflows/methods/methodSharepoint/actions/copyFile.py
index 92ce88a2..3883a681 100644
--- a/modules/workflows/methods/methodSharepoint/actions/copyFile.py
+++ b/modules/workflows/methods/methodSharepoint/actions/copyFile.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py b/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py
index 793e07c9..787c55fd 100644
--- a/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py
+++ b/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodSharepoint/actions/findDocumentPath.py b/modules/workflows/methods/methodSharepoint/actions/findDocumentPath.py
index 722dbc99..f04c703b 100644
--- a/modules/workflows/methods/methodSharepoint/actions/findDocumentPath.py
+++ b/modules/workflows/methods/methodSharepoint/actions/findDocumentPath.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodSharepoint/actions/findSiteByUrl.py b/modules/workflows/methods/methodSharepoint/actions/findSiteByUrl.py
index 62b6dd94..99a9a7d4 100644
--- a/modules/workflows/methods/methodSharepoint/actions/findSiteByUrl.py
+++ b/modules/workflows/methods/methodSharepoint/actions/findSiteByUrl.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodSharepoint/actions/listDocuments.py b/modules/workflows/methods/methodSharepoint/actions/listDocuments.py
index 318271c3..23313b47 100644
--- a/modules/workflows/methods/methodSharepoint/actions/listDocuments.py
+++ b/modules/workflows/methods/methodSharepoint/actions/listDocuments.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodSharepoint/actions/readDocuments.py b/modules/workflows/methods/methodSharepoint/actions/readDocuments.py
index 542ab2e8..f483bc3c 100644
--- a/modules/workflows/methods/methodSharepoint/actions/readDocuments.py
+++ b/modules/workflows/methods/methodSharepoint/actions/readDocuments.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodSharepoint/actions/uploadDocument.py b/modules/workflows/methods/methodSharepoint/actions/uploadDocument.py
index c68133d5..0f56b72f 100644
--- a/modules/workflows/methods/methodSharepoint/actions/uploadDocument.py
+++ b/modules/workflows/methods/methodSharepoint/actions/uploadDocument.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodSharepoint/actions/uploadFile.py b/modules/workflows/methods/methodSharepoint/actions/uploadFile.py
index 56e9f0b2..b263a34d 100644
--- a/modules/workflows/methods/methodSharepoint/actions/uploadFile.py
+++ b/modules/workflows/methods/methodSharepoint/actions/uploadFile.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
diff --git a/modules/workflows/methods/methodSharepoint/helpers/__init__.py b/modules/workflows/methods/methodSharepoint/helpers/__init__.py
index cc1293b3..111dcd82 100644
--- a/modules/workflows/methods/methodSharepoint/helpers/__init__.py
+++ b/modules/workflows/methods/methodSharepoint/helpers/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Helper modules for SharePoint method operations."""
diff --git a/modules/workflows/methods/methodSharepoint/helpers/apiClient.py b/modules/workflows/methods/methodSharepoint/helpers/apiClient.py
index 309497a4..5aee3a11 100644
--- a/modules/workflows/methods/methodSharepoint/helpers/apiClient.py
+++ b/modules/workflows/methods/methodSharepoint/helpers/apiClient.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodSharepoint/helpers/connection.py b/modules/workflows/methods/methodSharepoint/helpers/connection.py
index 3c2ce16d..2694182c 100644
--- a/modules/workflows/methods/methodSharepoint/helpers/connection.py
+++ b/modules/workflows/methods/methodSharepoint/helpers/connection.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodSharepoint/helpers/documentParsing.py b/modules/workflows/methods/methodSharepoint/helpers/documentParsing.py
index 9903568d..cf1b0755 100644
--- a/modules/workflows/methods/methodSharepoint/helpers/documentParsing.py
+++ b/modules/workflows/methods/methodSharepoint/helpers/documentParsing.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodSharepoint/helpers/pathProcessing.py b/modules/workflows/methods/methodSharepoint/helpers/pathProcessing.py
index 3e1a94f1..cfa2c073 100644
--- a/modules/workflows/methods/methodSharepoint/helpers/pathProcessing.py
+++ b/modules/workflows/methods/methodSharepoint/helpers/pathProcessing.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodSharepoint/helpers/siteDiscovery.py b/modules/workflows/methods/methodSharepoint/helpers/siteDiscovery.py
index f59de8f7..495063e7 100644
--- a/modules/workflows/methods/methodSharepoint/helpers/siteDiscovery.py
+++ b/modules/workflows/methods/methodSharepoint/helpers/siteDiscovery.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/methods/methodSharepoint/methodSharepoint.py b/modules/workflows/methods/methodSharepoint/methodSharepoint.py
index 78e462d7..dfc45274 100644
--- a/modules/workflows/methods/methodSharepoint/methodSharepoint.py
+++ b/modules/workflows/methods/methodSharepoint/methodSharepoint.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
diff --git a/modules/workflows/processing/adaptive/__init__.py b/modules/workflows/processing/adaptive/__init__.py
index c397aeb6..39f75a08 100644
--- a/modules/workflows/processing/adaptive/__init__.py
+++ b/modules/workflows/processing/adaptive/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# adaptive module for Dynamic mode
# Provides adaptive learning capabilities
diff --git a/modules/workflows/processing/adaptive/adaptiveLearningEngine.py b/modules/workflows/processing/adaptive/adaptiveLearningEngine.py
index 18588cf2..945acb60 100644
--- a/modules/workflows/processing/adaptive/adaptiveLearningEngine.py
+++ b/modules/workflows/processing/adaptive/adaptiveLearningEngine.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# adaptiveLearningEngine.py
# Enhanced learning engine that tracks validation patterns and adapts prompts
diff --git a/modules/workflows/processing/adaptive/contentValidator.py b/modules/workflows/processing/adaptive/contentValidator.py
index 15e1dc65..d3ae0d7d 100644
--- a/modules/workflows/processing/adaptive/contentValidator.py
+++ b/modules/workflows/processing/adaptive/contentValidator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# contentValidator.py
# Content validation for adaptive Dynamic mode
diff --git a/modules/workflows/processing/adaptive/learningEngine.py b/modules/workflows/processing/adaptive/learningEngine.py
index 8fb2f958..006c63ad 100644
--- a/modules/workflows/processing/adaptive/learningEngine.py
+++ b/modules/workflows/processing/adaptive/learningEngine.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# learningEngine.py
# Learning engine for adaptive Dynamic mode
diff --git a/modules/workflows/processing/adaptive/progressTracker.py b/modules/workflows/processing/adaptive/progressTracker.py
index 80c570ed..986edaf1 100644
--- a/modules/workflows/processing/adaptive/progressTracker.py
+++ b/modules/workflows/processing/adaptive/progressTracker.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# progressTracker.py
# Progress tracking for adaptive Dynamic mode
diff --git a/modules/workflows/processing/core/__init__.py b/modules/workflows/processing/core/__init__.py
index 784fe27d..fa549c25 100644
--- a/modules/workflows/processing/core/__init__.py
+++ b/modules/workflows/processing/core/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# Core workflow processing modules
diff --git a/modules/workflows/processing/core/actionExecutor.py b/modules/workflows/processing/core/actionExecutor.py
index 3156aa4b..f0f0f20d 100644
--- a/modules/workflows/processing/core/actionExecutor.py
+++ b/modules/workflows/processing/core/actionExecutor.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# actionExecutor.py
# Action execution functionality for workflows
diff --git a/modules/workflows/processing/core/messageCreator.py b/modules/workflows/processing/core/messageCreator.py
index 00aebc20..ed870784 100644
--- a/modules/workflows/processing/core/messageCreator.py
+++ b/modules/workflows/processing/core/messageCreator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# messageCreator.py
# Generic message creation for all workflow phases
diff --git a/modules/workflows/processing/core/taskPlanner.py b/modules/workflows/processing/core/taskPlanner.py
index 8401c2a3..ee248cbf 100644
--- a/modules/workflows/processing/core/taskPlanner.py
+++ b/modules/workflows/processing/core/taskPlanner.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# taskPlanner.py
# Task planning functionality for workflows
@@ -197,4 +197,4 @@ class TaskPlanner:
logger.error(f"Error in generateTaskPlan: {str(e)}")
raise
-
\ No newline at end of file
+
diff --git a/modules/workflows/processing/core/validator.py b/modules/workflows/processing/core/validator.py
index 67c685e8..fce1a808 100644
--- a/modules/workflows/processing/core/validator.py
+++ b/modules/workflows/processing/core/validator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# validator.py
# Validation logic for workflows
diff --git a/modules/workflows/processing/modes/__init__.py b/modules/workflows/processing/modes/__init__.py
index 36d96e63..81bdbca9 100644
--- a/modules/workflows/processing/modes/__init__.py
+++ b/modules/workflows/processing/modes/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# Workflow mode implementations
diff --git a/modules/workflows/processing/modes/modeAutomation.py b/modules/workflows/processing/modes/modeAutomation.py
index b6beabd3..46f00a79 100644
--- a/modules/workflows/processing/modes/modeAutomation.py
+++ b/modules/workflows/processing/modes/modeAutomation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# modeAutomation.py
# Automation mode implementation for workflows with predefined action plans
diff --git a/modules/workflows/processing/modes/modeBase.py b/modules/workflows/processing/modes/modeBase.py
index 684f5d52..89e07ecf 100644
--- a/modules/workflows/processing/modes/modeBase.py
+++ b/modules/workflows/processing/modes/modeBase.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# modeBase.py
# Abstract base class for workflow modes
diff --git a/modules/workflows/processing/modes/modeDynamic.py b/modules/workflows/processing/modes/modeDynamic.py
index 045835fa..2d7dd8ac 100644
--- a/modules/workflows/processing/modes/modeDynamic.py
+++ b/modules/workflows/processing/modes/modeDynamic.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# modeDynamic.py
# Dynamic mode implementation for workflows
diff --git a/modules/workflows/processing/shared/__init__.py b/modules/workflows/processing/shared/__init__.py
index bc0c6178..e28f3b80 100644
--- a/modules/workflows/processing/shared/__init__.py
+++ b/modules/workflows/processing/shared/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# Shared workflow utilities
diff --git a/modules/workflows/processing/shared/executionState.py b/modules/workflows/processing/shared/executionState.py
index e5e48a01..e772a9ae 100644
--- a/modules/workflows/processing/shared/executionState.py
+++ b/modules/workflows/processing/shared/executionState.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# executionState.py
# Contains all execution state management logic
@@ -79,4 +79,4 @@ def shouldContinue(observation=None, review=None, current_step: int = 0, max_ste
return True
except Exception as e:
logger.warning(f"Error in shouldContinue: {e}")
- return False
\ No newline at end of file
+ return False
diff --git a/modules/workflows/processing/shared/methodDiscovery.py b/modules/workflows/processing/shared/methodDiscovery.py
index 9271585c..2fdc0b85 100644
--- a/modules/workflows/processing/shared/methodDiscovery.py
+++ b/modules/workflows/processing/shared/methodDiscovery.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# methodDiscovery.py
# Method discovery and management for workflow execution
diff --git a/modules/workflows/processing/shared/parameterValidation.py b/modules/workflows/processing/shared/parameterValidation.py
index f8045b28..8e12ca7c 100644
--- a/modules/workflows/processing/shared/parameterValidation.py
+++ b/modules/workflows/processing/shared/parameterValidation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Universal parameter validation + coercion for workflow actions.
diff --git a/modules/workflows/processing/shared/placeholderFactory.py b/modules/workflows/processing/shared/placeholderFactory.py
index 430204bd..db9e71d9 100644
--- a/modules/workflows/processing/shared/placeholderFactory.py
+++ b/modules/workflows/processing/shared/placeholderFactory.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Placeholder Factory
diff --git a/modules/workflows/processing/shared/promptGenerationActionsDynamic.py b/modules/workflows/processing/shared/promptGenerationActionsDynamic.py
index 7415df93..a902f88f 100644
--- a/modules/workflows/processing/shared/promptGenerationActionsDynamic.py
+++ b/modules/workflows/processing/shared/promptGenerationActionsDynamic.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Dynamic Mode Prompt Generation
diff --git a/modules/workflows/processing/shared/promptGenerationTaskplan.py b/modules/workflows/processing/shared/promptGenerationTaskplan.py
index 11a54ca1..a29fee2b 100644
--- a/modules/workflows/processing/shared/promptGenerationTaskplan.py
+++ b/modules/workflows/processing/shared/promptGenerationTaskplan.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Task Planning Prompt Generation
diff --git a/modules/workflows/processing/workflowProcessor.py b/modules/workflows/processing/workflowProcessor.py
index 5123f934..8a41795c 100644
--- a/modules/workflows/processing/workflowProcessor.py
+++ b/modules/workflows/processing/workflowProcessor.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# workflowProcessor.py
# Main workflow processor with delegation pattern
diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py
index 7f06b325..cb23399b 100644
--- a/modules/workflows/workflowManager.py
+++ b/modules/workflows/workflowManager.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Dict, Any, List, Optional
import logging
diff --git a/scripts/script_analyze_function_imports.py b/scripts/script_analyze_function_imports.py
index 6a3118d2..8c007227 100644
--- a/scripts/script_analyze_function_imports.py
+++ b/scripts/script_analyze_function_imports.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Analyze function-level imports to determine which could be moved to header.
diff --git a/scripts/script_analyze_imports.py b/scripts/script_analyze_imports.py
index c63a556e..7d9a6bb8 100644
--- a/scripts/script_analyze_imports.py
+++ b/scripts/script_analyze_imports.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Analyze all imports in the gateway codebase and generate a CSV report.
diff --git a/scripts/script_db_export_migration.py b/scripts/script_db_export_migration.py
index 5a2f9214..0f72726a 100644
--- a/scripts/script_db_export_migration.py
+++ b/scripts/script_db_export_migration.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Datenbank Export-Tool für Migration.
diff --git a/scripts/script_generate_container_diagram.py b/scripts/script_generate_container_diagram.py
index 7f6243c9..44cfc89a 100644
--- a/scripts/script_generate_container_diagram.py
+++ b/scripts/script_generate_container_diagram.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Generate a simplified draw.io diagram showing container-to-container imports.
diff --git a/scripts/script_generate_import_diagram.py b/scripts/script_generate_import_diagram.py
index 0a7dcdd9..40d5996c 100644
--- a/scripts/script_generate_import_diagram.py
+++ b/scripts/script_generate_import_diagram.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Generate a draw.io diagram from import_analysis.csv
diff --git a/scripts/script_migrate_feature_instance_refs.py b/scripts/script_migrate_feature_instance_refs.py
index 8af55a6c..5a34e12a 100644
--- a/scripts/script_migrate_feature_instance_refs.py
+++ b/scripts/script_migrate_feature_instance_refs.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Persistent DB migration: rewrite raw ``featureInstanceId`` UUIDs in stored
diff --git a/scripts/script_remove_redundant_imports.py b/scripts/script_remove_redundant_imports.py
index 1c83eb9e..b138b4cb 100644
--- a/scripts/script_remove_redundant_imports.py
+++ b/scripts/script_remove_redundant_imports.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Remove redundant function-level imports that already exist in the header.
diff --git a/scripts/script_security_encrypt_all_env_files.py b/scripts/script_security_encrypt_all_env_files.py
index cceae83d..cbee9116 100644
--- a/scripts/script_security_encrypt_all_env_files.py
+++ b/scripts/script_security_encrypt_all_env_files.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Tool for encrypting all *_SECRET variables in all environment files.
diff --git a/scripts/script_security_encrypt_config_value.py b/scripts/script_security_encrypt_config_value.py
index 512d9958..da84cc14 100644
--- a/scripts/script_security_encrypt_config_value.py
+++ b/scripts/script_security_encrypt_config_value.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Tool for encrypting configuration values.
diff --git a/scripts/script_security_generate_master_keys.py b/scripts/script_security_generate_master_keys.py
index 6da55d26..d55285cd 100644
--- a/scripts/script_security_generate_master_keys.py
+++ b/scripts/script_security_generate_master_keys.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Generate secure master keys for all environments.
diff --git a/scripts/script_stats_durations_from_log.py b/scripts/script_stats_durations_from_log.py
index 24176f2c..ef9c3dc6 100644
--- a/scripts/script_stats_durations_from_log.py
+++ b/scripts/script_stats_durations_from_log.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import argparse
import csv
diff --git a/scripts/script_stats_get_codelines.py b/scripts/script_stats_get_codelines.py
index 1b4fd61e..37270fad 100644
--- a/scripts/script_stats_get_codelines.py
+++ b/scripts/script_stats_get_codelines.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Script to count code lines in a folder and its subfolders.
@@ -139,4 +139,4 @@ def main():
print(f"TOTAL LINES OF CODE: {total_lines}")
if __name__ == '__main__':
- main()
\ No newline at end of file
+ main()
diff --git a/scripts/script_stats_showUnusedFunctions.py b/scripts/script_stats_showUnusedFunctions.py
index f7f6b2c3..4dc8745b 100644
--- a/scripts/script_stats_showUnusedFunctions.py
+++ b/scripts/script_stats_showUnusedFunctions.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Script to find unused functions in Python files.
diff --git a/tests/__init__.py b/tests/__init__.py
index 77f3aaa9..1240b75c 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Test suite for PowerOn gateway modules
diff --git a/tests/conftest.py b/tests/conftest.py
index 9a70b5e0..2ac200ce 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Pytest configuration file for test suite.
diff --git a/tests/demo/test_pwg_demo_bootstrap.py b/tests/demo/test_pwg_demo_bootstrap.py
index 7bc38345..cd256540 100644
--- a/tests/demo/test_pwg_demo_bootstrap.py
+++ b/tests/demo/test_pwg_demo_bootstrap.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""T6 — PWG-Pilot demo bootstrap & idempotency tests.
diff --git a/tests/eval/__init__.py b/tests/eval/__init__.py
index fde23b13..e2dea687 100644
--- a/tests/eval/__init__.py
+++ b/tests/eval/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Eval harness for the Feature Data Sub-Agent (Phase 1.5)."""
diff --git a/tests/eval/fakeFeatureDataProvider.py b/tests/eval/fakeFeatureDataProvider.py
index 55557e7d..7081dfd4 100644
--- a/tests/eval/fakeFeatureDataProvider.py
+++ b/tests/eval/fakeFeatureDataProvider.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""In-memory drop-in for FeatureDataProvider used by the eval harness.
diff --git a/tests/eval/runTrusteeBenchmark.py b/tests/eval/runTrusteeBenchmark.py
index 7622b3d0..69ae3499 100644
--- a/tests/eval/runTrusteeBenchmark.py
+++ b/tests/eval/runTrusteeBenchmark.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Trustee Sub-Agent Eval Harness (Phase 1.5).
diff --git a/tests/fixtures/loadRedmineSnapshot.py b/tests/fixtures/loadRedmineSnapshot.py
index e0a501d7..6a24d35a 100644
--- a/tests/fixtures/loadRedmineSnapshot.py
+++ b/tests/fixtures/loadRedmineSnapshot.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Load ``redmineSnapshot.json`` into in-memory ``RedmineTicketDto`` objects.
diff --git a/tests/fixtures/trusteeBenchmark/__init__.py b/tests/fixtures/trusteeBenchmark/__init__.py
index 52f83ff7..ee9e9c1c 100644
--- a/tests/fixtures/trusteeBenchmark/__init__.py
+++ b/tests/fixtures/trusteeBenchmark/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Trustee benchmark fixture: synthetic but realistic Swiss KMU accounting data.
diff --git a/tests/fixtures/trusteeBenchmark/loadTrusteeBenchmarkFixture.py b/tests/fixtures/trusteeBenchmark/loadTrusteeBenchmarkFixture.py
index 5eb77867..3b6f02ca 100644
--- a/tests/fixtures/trusteeBenchmark/loadTrusteeBenchmarkFixture.py
+++ b/tests/fixtures/trusteeBenchmark/loadTrusteeBenchmarkFixture.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Synthetic Trustee benchmark fixture for the Feature Data Sub-Agent eval.
diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py
index 81e51c0f..6707ed42 100644
--- a/tests/functional/__init__.py
+++ b/tests/functional/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Functional tests directory.
diff --git a/tests/functional/test01_ai_model_selection.py b/tests/functional/test01_ai_model_selection.py
index 4c299a26..370dc471 100644
--- a/tests/functional/test01_ai_model_selection.py
+++ b/tests/functional/test01_ai_model_selection.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
AI Model Selection Test - Prints prioritized fallback model lists for all interface calls
diff --git a/tests/functional/test02_ai_models.py b/tests/functional/test02_ai_models.py
index 4569455e..73fb37df 100644
--- a/tests/functional/test02_ai_models.py
+++ b/tests/functional/test02_ai_models.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
AI Models Test - Tests ALL operation types on ALL models that support them
diff --git a/tests/functional/test03_ai_operations.py b/tests/functional/test03_ai_operations.py
index ee38af8b..65ed2ddc 100644
--- a/tests/functional/test03_ai_operations.py
+++ b/tests/functional/test03_ai_operations.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Test script for methodAi operations.
diff --git a/tests/functional/test04_ai_behavior.py b/tests/functional/test04_ai_behavior.py
index 276b9283..809e345a 100644
--- a/tests/functional/test04_ai_behavior.py
+++ b/tests/functional/test04_ai_behavior.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
AI Behavior Test - Tests actual AI responses with different prompt structures
diff --git a/tests/functional/test07_json_merge.py b/tests/functional/test07_json_merge.py
index 504858f0..c0597e32 100644
--- a/tests/functional/test07_json_merge.py
+++ b/tests/functional/test07_json_merge.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Test JSON string accumulation for broken JSON iterations - String accumulation approach"""
import json
diff --git a/tests/functional/test08_json_finalization.py b/tests/functional/test08_json_finalization.py
index 04de9271..5d9e86c0 100644
--- a/tests/functional/test08_json_finalization.py
+++ b/tests/functional/test08_json_finalization.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Test JSON finalization process after accumulation is complete.
diff --git a/tests/functional/test12_json_split_merge.py b/tests/functional/test12_json_split_merge.py
index 6e10c58c..b63b2616 100644
--- a/tests/functional/test12_json_split_merge.py
+++ b/tests/functional/test12_json_split_merge.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
JSON Split and Merge Test 12 - Tests JSON splitting and merging using workflow tools
diff --git a/tests/functional/test13_json_completion_cuts.py b/tests/functional/test13_json_completion_cuts.py
index 494678fc..75c34527 100644
--- a/tests/functional/test13_json_completion_cuts.py
+++ b/tests/functional/test13_json_completion_cuts.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
JSON Completion Test 13 - Tests JSON completion at various cut positions
diff --git a/tests/functional/test14_json_continuation_context.py b/tests/functional/test14_json_continuation_context.py
index ae7ea00e..204a4f49 100644
--- a/tests/functional/test14_json_continuation_context.py
+++ b/tests/functional/test14_json_continuation_context.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
JSON Continuation Context Test 14 - Tests getContexts() with a specific cut JSON from debug prompts.
diff --git a/tests/functional/test_kpi_full.py b/tests/functional/test_kpi_full.py
index b15e0d7e..aa8d8540 100644
--- a/tests/functional/test_kpi_full.py
+++ b/tests/functional/test_kpi_full.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Test full KPI extraction and validation flow"""
import json
diff --git a/tests/functional/test_kpi_incomplete.py b/tests/functional/test_kpi_incomplete.py
index e7c728e8..b9125d9f 100644
--- a/tests/functional/test_kpi_incomplete.py
+++ b/tests/functional/test_kpi_incomplete.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Test KPI extraction with incomplete JSON"""
import json
diff --git a/tests/functional/test_kpi_path.py b/tests/functional/test_kpi_path.py
index 7814f991..c0a32862 100644
--- a/tests/functional/test_kpi_path.py
+++ b/tests/functional/test_kpi_path.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Test KPI path extraction"""
import json
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
index 21e803ee..bd6475cb 100644
--- a/tests/integration/__init__.py
+++ b/tests/integration/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Integration tests
diff --git a/tests/integration/automation2/__init__.py b/tests/integration/automation2/__init__.py
index d30846a4..8b7785dc 100644
--- a/tests/integration/automation2/__init__.py
+++ b/tests/integration/automation2/__init__.py
@@ -1,2 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Integration tests for automation2 typed bindings (Phase-5 Schicht-4)."""
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 fb109337..34a8b505 100644
--- a/tests/integration/automation2/test_pick_not_push_migration_v2.py
+++ b/tests/integration/automation2/test_pick_not_push_migration_v2.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Phase-5 Schicht-4 integration test (T11): the typed-bindings pipeline must
produce identical action-call parameters whether a workflow stores
diff --git a/tests/integration/extraction/test_extract_udm_pipeline.py b/tests/integration/extraction/test_extract_udm_pipeline.py
index 7c9b2bf8..c356b0ee 100644
--- a/tests/integration/extraction/test_extract_udm_pipeline.py
+++ b/tests/integration/extraction/test_extract_udm_pipeline.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy
from modules.serviceCenter.services.serviceExtraction.subPipeline import runExtraction
diff --git a/tests/integration/mandates/test_createMandate.py b/tests/integration/mandates/test_createMandate.py
index 1ad24b75..1c66e75a 100644
--- a/tests/integration/mandates/test_createMandate.py
+++ b/tests/integration/mandates/test_createMandate.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Integration tests for ``AppObjects.createMandate``.
diff --git a/tests/integration/mandates/test_provisionMandate.py b/tests/integration/mandates/test_provisionMandate.py
index b88da4ee..d8506cb4 100644
--- a/tests/integration/mandates/test_provisionMandate.py
+++ b/tests/integration/mandates/test_provisionMandate.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Integration tests for the slug-derivation contract that
diff --git a/tests/integration/mandates/test_updateMandate.py b/tests/integration/mandates/test_updateMandate.py
index 385f7fa9..c38875ba 100644
--- a/tests/integration/mandates/test_updateMandate.py
+++ b/tests/integration/mandates/test_updateMandate.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Integration tests for ``AppObjects.updateMandate``.
diff --git a/tests/integration/rbac/__init__.py b/tests/integration/rbac/__init__.py
index 25122eed..86a9d18e 100644
--- a/tests/integration/rbac/__init__.py
+++ b/tests/integration/rbac/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Integration tests for RBAC system."""
diff --git a/tests/integration/rbac/test_platform_admin_flag.py b/tests/integration/rbac/test_platform_admin_flag.py
index d4cdbe9b..11ad98f8 100644
--- a/tests/integration/rbac/test_platform_admin_flag.py
+++ b/tests/integration/rbac/test_platform_admin_flag.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Integration tests for the SysAdmin / PlatformAdmin authority split.
diff --git a/tests/integration/rbac/test_rbac_database.py b/tests/integration/rbac/test_rbac_database.py
index dbf56dd3..33673cc2 100644
--- a/tests/integration/rbac/test_rbac_database.py
+++ b/tests/integration/rbac/test_rbac_database.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Integration tests for RBAC database filtering.
diff --git a/tests/integration/trustee/__init__.py b/tests/integration/trustee/__init__.py
index d02d6efc..d7c29a91 100644
--- a/tests/integration/trustee/__init__.py
+++ b/tests/integration/trustee/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
#
# Trustee feature integration tests.
diff --git a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py
index b7b952b8..606411e9 100644
--- a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py
+++ b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Plan #2 Track A2 (T4): Trustee Spesenbelege Live-E2E Integration-Test.
diff --git a/tests/integration/users/test_updateUser.py b/tests/integration/users/test_updateUser.py
index 1c7afa29..a557bb3c 100644
--- a/tests/integration/users/test_updateUser.py
+++ b/tests/integration/users/test_updateUser.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Integration tests for ``AppObjects.updateUser`` partial-update semantics.
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 3fc75f54..f59292b4 100644
--- a/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py
+++ b/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Integration: executeGraph with flow.loop + data.aggregate (no AI), then data.consolidate on same outputs.
import pytest
diff --git a/tests/integration/workflows/test_workflow_execution.py b/tests/integration/workflows/test_workflow_execution.py
index a2b69576..ea799406 100644
--- a/tests/integration/workflows/test_workflow_execution.py
+++ b/tests/integration/workflows/test_workflow_execution.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Integration tests for workflow execution
diff --git a/tests/serviceAi/test_allowed_models_whitelist.py b/tests/serviceAi/test_allowed_models_whitelist.py
index 4593afd9..52a1abf7 100644
--- a/tests/serviceAi/test_allowed_models_whitelist.py
+++ b/tests/serviceAi/test_allowed_models_whitelist.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import pytest
from modules.datamodels.datamodelAi import AiCallOptions
diff --git a/tests/serviceGeneration/test_inline_image_paragraph.py b/tests/serviceGeneration/test_inline_image_paragraph.py
index be0c5d19..65673138 100644
--- a/tests/serviceGeneration/test_inline_image_paragraph.py
+++ b/tests/serviceGeneration/test_inline_image_paragraph.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import pytest
from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson
diff --git a/tests/serviceGeneration/test_large_document_render.py b/tests/serviceGeneration/test_large_document_render.py
index 8b757e64..245e644c 100644
--- a/tests/serviceGeneration/test_large_document_render.py
+++ b/tests/serviceGeneration/test_large_document_render.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""A3 / AC15: lazy file-reference image resolution for large documents.
diff --git a/tests/serviceGeneration/test_layout_primitives.py b/tests/serviceGeneration/test_layout_primitives.py
index 1c9e6c5e..7fd4fa88 100644
--- a/tests/serviceGeneration/test_layout_primitives.py
+++ b/tests/serviceGeneration/test_layout_primitives.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""A3: layout primitives (cover_page, image_grid).
diff --git a/tests/serviceGeneration/test_md_to_json_consolidation.py b/tests/serviceGeneration/test_md_to_json_consolidation.py
index 83118374..17184f51 100644
--- a/tests/serviceGeneration/test_md_to_json_consolidation.py
+++ b/tests/serviceGeneration/test_md_to_json_consolidation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import pytest
from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson
diff --git a/tests/serviceGeneration/test_style_resolver.py b/tests/serviceGeneration/test_style_resolver.py
index e7d629cd..a08314b8 100644
--- a/tests/serviceGeneration/test_style_resolver.py
+++ b/tests/serviceGeneration/test_style_resolver.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import pytest
from modules.serviceCenter.services.serviceGeneration.styleDefaults import (
diff --git a/tests/test_dateRange.py b/tests/test_dateRange.py
index dc8c2619..e68f4a08 100644
--- a/tests/test_dateRange.py
+++ b/tests/test_dateRange.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unit tests for `modules.shared.dateRange`.
diff --git a/tests/test_service_redmine_orphans.py b/tests/test_service_redmine_orphans.py
index f5a22c7a..829bbd18 100644
--- a/tests/test_service_redmine_orphans.py
+++ b/tests/test_service_redmine_orphans.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Pure-Python unit tests for the orphan detection in
``serviceRedmineStats._countOrphans``.
diff --git a/tests/test_service_redmine_stats.py b/tests/test_service_redmine_stats.py
index aecd2caf..9e9d86ab 100644
--- a/tests/test_service_redmine_stats.py
+++ b/tests/test_service_redmine_stats.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for the pure aggregation in ``serviceRedmineStats._aggregate``.
diff --git a/tests/test_service_redmine_stats_cache.py b/tests/test_service_redmine_stats_cache.py
index 47d98a9d..11dd823b 100644
--- a/tests/test_service_redmine_stats_cache.py
+++ b/tests/test_service_redmine_stats_cache.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for ``RedmineStatsCache``.
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
index e2e77ecd..2ce5982f 100644
--- a/tests/unit/__init__.py
+++ b/tests/unit/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unit tests
diff --git a/tests/unit/aicore/test_aicorePluginOpenai_temperature.py b/tests/unit/aicore/test_aicorePluginOpenai_temperature.py
index eb2d7cec..1cccc206 100644
--- a/tests/unit/aicore/test_aicorePluginOpenai_temperature.py
+++ b/tests/unit/aicore/test_aicorePluginOpenai_temperature.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests: temperature handling for OpenAI chat-completions models.
diff --git a/tests/unit/auth/test_mfaService.py b/tests/unit/auth/test_mfaService.py
index 5010ceef..ed42f34d 100644
--- a/tests/unit/auth/test_mfaService.py
+++ b/tests/unit/auth/test_mfaService.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unit tests for modules.auth.mfaService.
diff --git a/tests/unit/connectors/test_connectorDbPostgre_failLoud.py b/tests/unit/connectors/test_connectorDbPostgre_failLoud.py
index 5fb505d7..df35a902 100644
--- a/tests/unit/connectors/test_connectorDbPostgre_failLoud.py
+++ b/tests/unit/connectors/test_connectorDbPostgre_failLoud.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests: PostgreSQL connector raises DatabaseQueryError on real failures.
diff --git a/tests/unit/connectors/test_connectorDbPostgre_pool.py b/tests/unit/connectors/test_connectorDbPostgre_pool.py
index 99dbab43..89e8b6ac 100644
--- a/tests/unit/connectors/test_connectorDbPostgre_pool.py
+++ b/tests/unit/connectors/test_connectorDbPostgre_pool.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Concurrency tests for the PostgreSQL connection pool.
diff --git a/tests/unit/connectors/test_connectorResolver.py b/tests/unit/connectors/test_connectorResolver.py
index 0ef82e81..2ae8566f 100644
--- a/tests/unit/connectors/test_connectorResolver.py
+++ b/tests/unit/connectors/test_connectorResolver.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
from types import SimpleNamespace
from modules.connectors.connectorResolver import _connection_uuid
diff --git a/tests/unit/connectors/test_connectorVoiceGoogle_sttHelpers.py b/tests/unit/connectors/test_connectorVoiceGoogle_sttHelpers.py
index 258dc0db..22871203 100644
--- a/tests/unit/connectors/test_connectorVoiceGoogle_sttHelpers.py
+++ b/tests/unit/connectors/test_connectorVoiceGoogle_sttHelpers.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Unit tests for Google STT helper config (no API calls)."""
from modules.connectors.connectorVoiceGoogle import _buildPrimarySttRecognitionFields
diff --git a/tests/unit/datamodels/test_docref.py b/tests/unit/datamodels/test_docref.py
index 2f2ee03d..9f66d121 100644
--- a/tests/unit/datamodels/test_docref.py
+++ b/tests/unit/datamodels/test_docref.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unit tests for document reference models in datamodelDocref.py
diff --git a/tests/unit/datamodels/test_udm_bridge.py b/tests/unit/datamodels/test_udm_bridge.py
index db52ffe6..d189b887 100644
--- a/tests/unit/datamodels/test_udm_bridge.py
+++ b/tests/unit/datamodels/test_udm_bridge.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart
from modules.datamodels.datamodelUdm import contentPartsToUdm, _udmToContentParts
diff --git a/tests/unit/datamodels/test_udm_models.py b/tests/unit/datamodels/test_udm_models.py
index 92d86a85..dbda8c92 100644
--- a/tests/unit/datamodels/test_udm_models.py
+++ b/tests/unit/datamodels/test_udm_models.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from modules.datamodels.datamodelUdm import UdmContentBlock, UdmDocument, UdmStructuralNode
diff --git a/tests/unit/datamodels/test_workflow_models.py b/tests/unit/datamodels/test_workflow_models.py
index 59e3736d..1563e524 100644
--- a/tests/unit/datamodels/test_workflow_models.py
+++ b/tests/unit/datamodels/test_workflow_models.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unit tests for workflow models in datamodelWorkflow.py
diff --git a/tests/unit/features/test_trustee_template_workflows.py b/tests/unit/features/test_trustee_template_workflows.py
index 388f2d29..2e6023dc 100644
--- a/tests/unit/features/test_trustee_template_workflows.py
+++ b/tests/unit/features/test_trustee_template_workflows.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Guardrails for Trustee ``getTemplateWorkflows`` graphs (new instance bootstrap)."""
from __future__ import annotations
diff --git a/tests/unit/features/trustee/test_accountingConnectorAbacus_balances.py b/tests/unit/features/trustee/test_accountingConnectorAbacus_balances.py
index ae1a39ad..7d4f3884 100644
--- a/tests/unit/features/trustee/test_accountingConnectorAbacus_balances.py
+++ b/tests/unit/features/trustee/test_accountingConnectorAbacus_balances.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for the Abacus connector's getAccountBalances aggregation logic."""
diff --git a/tests/unit/features/trustee/test_accountingConnectorBexio_balances.py b/tests/unit/features/trustee/test_accountingConnectorBexio_balances.py
index 945c7c95..ca9ae84b 100644
--- a/tests/unit/features/trustee/test_accountingConnectorBexio_balances.py
+++ b/tests/unit/features/trustee/test_accountingConnectorBexio_balances.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for the Bexio connector's getAccountBalances aggregation logic."""
diff --git a/tests/unit/features/trustee/test_accountingConnectorRma_balances.py b/tests/unit/features/trustee/test_accountingConnectorRma_balances.py
index b6e43717..6ea3f9d8 100644
--- a/tests/unit/features/trustee/test_accountingConnectorRma_balances.py
+++ b/tests/unit/features/trustee/test_accountingConnectorRma_balances.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for the RMA connector's getAccountBalances implementation.
diff --git a/tests/unit/features/trustee/test_accountingDataSync_balances.py b/tests/unit/features/trustee/test_accountingDataSync_balances.py
index 711c9808..d9791c78 100644
--- a/tests/unit/features/trustee/test_accountingDataSync_balances.py
+++ b/tests/unit/features/trustee/test_accountingDataSync_balances.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for the local-fallback cumulative balance computation in
``AccountingDataSync._buildLocalBalanceFallback`` and the connector handoff
diff --git a/tests/unit/graphicalEditor/test_action_node_connection_provenance.py b/tests/unit/graphicalEditor/test_action_node_connection_provenance.py
index 610d35c9..8e6d1159 100644
--- a/tests/unit/graphicalEditor/test_action_node_connection_provenance.py
+++ b/tests/unit/graphicalEditor/test_action_node_connection_provenance.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
from modules.workflowAutomation.engine.executors.actionNodeExecutor import _buildConnectionRefDict
diff --git a/tests/unit/graphicalEditor/test_adapter_validator.py b/tests/unit/graphicalEditor/test_adapter_validator.py
index ad507daa..5ac3eb8f 100644
--- a/tests/unit/graphicalEditor/test_adapter_validator.py
+++ b/tests/unit/graphicalEditor/test_adapter_validator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Tests for the Schicht-3 Adapter Validator (Phase 3).
diff --git a/tests/unit/graphicalEditor/test_condition_operator_catalog.py b/tests/unit/graphicalEditor/test_condition_operator_catalog.py
index ce02c083..05f6cb7b 100644
--- a/tests/unit/graphicalEditor/test_condition_operator_catalog.py
+++ b/tests/unit/graphicalEditor/test_condition_operator_catalog.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Tests for backend-driven condition operator catalog."""
from modules.workflowAutomation.editor.conditionOperators import (
diff --git a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py
index e81a0a4f..671a6e62 100644
--- a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py
+++ b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Schicht-4 / Phase-5 follow-up: assert that all Trustee + Redmine node
diff --git a/tests/unit/graphicalEditor/test_node_adapter.py b/tests/unit/graphicalEditor/test_node_adapter.py
index 634f76d2..2fc39dde 100644
--- a/tests/unit/graphicalEditor/test_node_adapter.py
+++ b/tests/unit/graphicalEditor/test_node_adapter.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Tests for the Schicht-3 NodeAdapter projection (Phase 3).
diff --git a/tests/unit/graphicalEditor/test_portTypes_catalog.py b/tests/unit/graphicalEditor/test_portTypes_catalog.py
index 9e97d475..4d2a0a38 100644
--- a/tests/unit/graphicalEditor/test_portTypes_catalog.py
+++ b/tests/unit/graphicalEditor/test_portTypes_catalog.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Catalog integrity + new Phase-1 schemas
(see wiki/c-work/1-plan/2026-04-typed-action-architecture.md).
diff --git a/tests/unit/graphicalEditor/test_port_schema_recursive.py b/tests/unit/graphicalEditor/test_port_schema_recursive.py
index cd32e461..00f3899e 100644
--- a/tests/unit/graphicalEditor/test_port_schema_recursive.py
+++ b/tests/unit/graphicalEditor/test_port_schema_recursive.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Port type catalog: nested provenance schemas (Typed Generic Handover)."""
from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG, _defaultForType
diff --git a/tests/unit/graphicalEditor/test_resolve_value_kind.py b/tests/unit/graphicalEditor/test_resolve_value_kind.py
index 497619e2..0e37ff92 100644
--- a/tests/unit/graphicalEditor/test_resolve_value_kind.py
+++ b/tests/unit/graphicalEditor/test_resolve_value_kind.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Tests for condition valueKind resolution."""
from modules.workflowAutomation.editor.conditionOperators import resolve_value_kind
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 6c6ff2cc..7663364d 100644
--- a/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py
+++ b/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths
from modules.workflowAutomation.engine.graphUtils import parse_graph_defined_schema, validateGraph
from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
diff --git a/tests/unit/interfaces/test_folderRbac.py b/tests/unit/interfaces/test_folderRbac.py
index f4b984aa..ea7ab1fb 100644
--- a/tests/unit/interfaces/test_folderRbac.py
+++ b/tests/unit/interfaces/test_folderRbac.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for folder RBAC two-user matrix (ownership & scope visibility)."""
diff --git a/tests/unit/methods/test_action_signature_validator.py b/tests/unit/methods/test_action_signature_validator.py
index a959989e..62883b44 100644
--- a/tests/unit/methods/test_action_signature_validator.py
+++ b/tests/unit/methods/test_action_signature_validator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Tests for the action-signature validator (Phase 2 of the Typed Action
diff --git a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py
index 060d04a6..fd75170a 100644
--- a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py
+++ b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Trustee node schema-compliance under the Pick-not-Push typed port system.
Verifies that:
diff --git a/tests/unit/rbac/__init__.py b/tests/unit/rbac/__init__.py
index 76c6c7d0..0afbbeb1 100644
--- a/tests/unit/rbac/__init__.py
+++ b/tests/unit/rbac/__init__.py
@@ -1,3 +1,3 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for RBAC system."""
diff --git a/tests/unit/rbac/test_rbac_bootstrap.py b/tests/unit/rbac/test_rbac_bootstrap.py
index 0e69b802..d444a543 100644
--- a/tests/unit/rbac/test_rbac_bootstrap.py
+++ b/tests/unit/rbac/test_rbac_bootstrap.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unit tests for RBAC bootstrap initialization.
diff --git a/tests/unit/rbac/test_rbac_permissions.py b/tests/unit/rbac/test_rbac_permissions.py
index 49458367..d82af432 100644
--- a/tests/unit/rbac/test_rbac_permissions.py
+++ b/tests/unit/rbac/test_rbac_permissions.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unit tests for RBAC permission resolution.
diff --git a/tests/unit/routes/test_folder_crud.py b/tests/unit/routes/test_folder_crud.py
index 66bad903..1af53e4d 100644
--- a/tests/unit/routes/test_folder_crud.py
+++ b/tests/unit/routes/test_folder_crud.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for folder CRUD operations in ComponentObjects."""
diff --git a/tests/unit/scripts/__init__.py b/tests/unit/scripts/__init__.py
index fdcc4f0e..06003961 100644
--- a/tests/unit/scripts/__init__.py
+++ b/tests/unit/scripts/__init__.py
@@ -1,2 +1,2 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
diff --git a/tests/unit/scripts/test_migrate_feature_instance_refs.py b/tests/unit/scripts/test_migrate_feature_instance_refs.py
index 80367b4e..c0ff9499 100644
--- a/tests/unit/scripts/test_migrate_feature_instance_refs.py
+++ b/tests/unit/scripts/test_migrate_feature_instance_refs.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Tests for ``scripts/script_migrate_feature_instance_refs.py``.
diff --git a/tests/unit/serviceAgent/test_action_tool_adapter_typed.py b/tests/unit/serviceAgent/test_action_tool_adapter_typed.py
index 44439957..2934eeb3 100644
--- a/tests/unit/serviceAgent/test_action_tool_adapter_typed.py
+++ b/tests/unit/serviceAgent/test_action_tool_adapter_typed.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Tests for the catalog-driven JSON-Schema generator in actionToolAdapter
diff --git a/tests/unit/serviceAgent/test_agentTrace_repairCounters.py b/tests/unit/serviceAgent/test_agentTrace_repairCounters.py
index 4a0909d1..befecea3 100644
--- a/tests/unit/serviceAgent/test_agentTrace_repairCounters.py
+++ b/tests/unit/serviceAgent/test_agentTrace_repairCounters.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for the repair-loop telemetry aggregation in agentLoop.
diff --git a/tests/unit/serviceAgent/test_field_neutralization.py b/tests/unit/serviceAgent/test_field_neutralization.py
index 6cd52974..005f34f0 100644
--- a/tests/unit/serviceAgent/test_field_neutralization.py
+++ b/tests/unit/serviceAgent/test_field_neutralization.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""A2: type/inheritance-aware field neutralization for feature source data.
diff --git a/tests/unit/serviceAgent/test_workflow_tools_crud.py b/tests/unit/serviceAgent/test_workflow_tools_crud.py
index 41e56ab6..7e818b31 100644
--- a/tests/unit/serviceAgent/test_workflow_tools_crud.py
+++ b/tests/unit/serviceAgent/test_workflow_tools_crud.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""T3 — Unit tests for the workflow-CRUD agent tools.
diff --git a/tests/unit/services/test_bootstrap_clickup.py b/tests/unit/services/test_bootstrap_clickup.py
index 4ed0c4f1..f8205297 100644
--- a/tests/unit/services/test_bootstrap_clickup.py
+++ b/tests/unit/services/test_bootstrap_clickup.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Bootstrap ClickUp tests with a fake service + knowledge service.
diff --git a/tests/unit/services/test_bootstrap_gdrive.py b/tests/unit/services/test_bootstrap_gdrive.py
index 2741332f..58970df9 100644
--- a/tests/unit/services/test_bootstrap_gdrive.py
+++ b/tests/unit/services/test_bootstrap_gdrive.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Bootstrap Google Drive tests with a fake adapter + knowledge service.
diff --git a/tests/unit/services/test_bootstrap_gmail.py b/tests/unit/services/test_bootstrap_gmail.py
index 86508adb..d2cf2734 100644
--- a/tests/unit/services/test_bootstrap_gmail.py
+++ b/tests/unit/services/test_bootstrap_gmail.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Bootstrap Gmail tests with a fake googleGet + knowledge service.
diff --git a/tests/unit/services/test_bootstrap_outlook.py b/tests/unit/services/test_bootstrap_outlook.py
index c5fea524..5dabc4e7 100644
--- a/tests/unit/services/test_bootstrap_outlook.py
+++ b/tests/unit/services/test_bootstrap_outlook.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Bootstrap Outlook tests with a fake adapter + knowledge service.
diff --git a/tests/unit/services/test_bootstrap_sharepoint.py b/tests/unit/services/test_bootstrap_sharepoint.py
index 91020765..cee22bc6 100644
--- a/tests/unit/services/test_bootstrap_sharepoint.py
+++ b/tests/unit/services/test_bootstrap_sharepoint.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Bootstrap SharePoint tests with a fake adapter + knowledge service.
diff --git a/tests/unit/services/test_clean_email_body.py b/tests/unit/services/test_clean_email_body.py
index a3ee01df..6827f2de 100644
--- a/tests/unit/services/test_clean_email_body.py
+++ b/tests/unit/services/test_clean_email_body.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for cleanEmailBody.
diff --git a/tests/unit/services/test_connection_purge.py b/tests/unit/services/test_connection_purge.py
index c32cb5b3..5acbcce2 100644
--- a/tests/unit/services/test_connection_purge.py
+++ b/tests/unit/services/test_connection_purge.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Purge tests for KnowledgeObjects.deleteFileContentIndexByConnectionId.
diff --git a/tests/unit/services/test_extraction_merge_strategy.py b/tests/unit/services/test_extraction_merge_strategy.py
index 784bb783..28a41629 100644
--- a/tests/unit/services/test_extraction_merge_strategy.py
+++ b/tests/unit/services/test_extraction_merge_strategy.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Test that runExtraction preserves per-part granularity when mergeStrategy=None.
diff --git a/tests/unit/services/test_featureDataAgent_schema.py b/tests/unit/services/test_featureDataAgent_schema.py
index 2b70532d..15b51b1c 100644
--- a/tests/unit/services/test_featureDataAgent_schema.py
+++ b/tests/unit/services/test_featureDataAgent_schema.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit test: feature data sub-agent schema context is rich enough.
diff --git a/tests/unit/services/test_ingestion_hash_stability.py b/tests/unit/services/test_ingestion_hash_stability.py
index df25a4f0..eb19736a 100644
--- a/tests/unit/services/test_ingestion_hash_stability.py
+++ b/tests/unit/services/test_ingestion_hash_stability.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Test that _computeIngestionHash is stable across re-extractions of the same source.
diff --git a/tests/unit/services/test_json_extraction_merging.py b/tests/unit/services/test_json_extraction_merging.py
index 49f430a8..138e5b62 100644
--- a/tests/unit/services/test_json_extraction_merging.py
+++ b/tests/unit/services/test_json_extraction_merging.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Test script for JSON extraction response detection and merging.
diff --git a/tests/unit/services/test_knowledge_ingest_consumer.py b/tests/unit/services/test_knowledge_ingest_consumer.py
index 9884079e..b523e918 100644
--- a/tests/unit/services/test_knowledge_ingest_consumer.py
+++ b/tests/unit/services/test_knowledge_ingest_consumer.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for KnowledgeIngestionConsumer event dispatch.
diff --git a/tests/unit/services/test_queryValidator.py b/tests/unit/services/test_queryValidator.py
index 0fb0b4a4..7aa6c01e 100644
--- a/tests/unit/services/test_queryValidator.py
+++ b/tests/unit/services/test_queryValidator.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for the Feature Data Sub-Agent QueryValidator.
diff --git a/tests/unit/services/test_renderer_pdf_smoke.py b/tests/unit/services/test_renderer_pdf_smoke.py
index 60c1a2ef..9e984003 100644
--- a/tests/unit/services/test_renderer_pdf_smoke.py
+++ b/tests/unit/services/test_renderer_pdf_smoke.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Smoke test: RendererPdf with every JSON section/element shape the pipeline supports.
diff --git a/tests/unit/services/test_trusteeOntology.py b/tests/unit/services/test_trusteeOntology.py
index 89d714c6..0699910a 100644
--- a/tests/unit/services/test_trusteeOntology.py
+++ b/tests/unit/services/test_trusteeOntology.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for the trustee ontology and the ontology-to-prompt compiler.
diff --git a/tests/unit/shared/test_mandateNameUtils.py b/tests/unit/shared/test_mandateNameUtils.py
index 6ef4bec1..11f7912d 100644
--- a/tests/unit/shared/test_mandateNameUtils.py
+++ b/tests/unit/shared/test_mandateNameUtils.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for mandateNameUtils (slug, validation, unique allocation)."""
diff --git a/tests/unit/teamsbot/test_directorPrompts.py b/tests/unit/teamsbot/test_directorPrompts.py
index b8bdaafc..9b23fe17 100644
--- a/tests/unit/teamsbot/test_directorPrompts.py
+++ b/tests/unit/teamsbot/test_directorPrompts.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for Teamsbot Director Prompts (Plan #5).
diff --git a/tests/unit/utils/test_json_utils.py b/tests/unit/utils/test_json_utils.py
index 6c0e4357..3ae21ad4 100644
--- a/tests/unit/utils/test_json_utils.py
+++ b/tests/unit/utils/test_json_utils.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unit tests for JSON utilities in jsonUtils.py
diff --git a/tests/unit/workflow/test_flow_executor_conditions.py b/tests/unit/workflow/test_flow_executor_conditions.py
index b16e8e5c..49af1a7a 100644
--- a/tests/unit/workflow/test_flow_executor_conditions.py
+++ b/tests/unit/workflow/test_flow_executor_conditions.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""FlowExecutor structured condition evaluation with Item dataRef."""
import pytest
diff --git a/tests/unit/workflow/test_switch_filtered_output.py b/tests/unit/workflow/test_switch_filtered_output.py
index ee9271d9..5346a3d6 100644
--- a/tests/unit/workflow/test_switch_filtered_output.py
+++ b/tests/unit/workflow/test_switch_filtered_output.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""flow.switch ContextBranch: filtered presentation + loop-ready items."""
import pytest
diff --git a/tests/unit/workflow/test_trusteeQueryData.py b/tests/unit/workflow/test_trusteeQueryData.py
index 93e0f4c5..8111e9c2 100644
--- a/tests/unit/workflow/test_trusteeQueryData.py
+++ b/tests/unit/workflow/test_trusteeQueryData.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for trustee.queryData helpers (pure-logic, no DB required)."""
diff --git a/tests/unit/workflow/test_workflowFileSchema.py b/tests/unit/workflow/test_workflowFileSchema.py
index 3eb0fb2c..3cea8989 100644
--- a/tests/unit/workflow/test_workflowFileSchema.py
+++ b/tests/unit/workflow/test_workflowFileSchema.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests for the workflow-file (versioned envelope) schema."""
diff --git a/tests/unit/workflows/test_featureInstanceRefMigration.py b/tests/unit/workflows/test_featureInstanceRefMigration.py
index 2ffb6682..dd363c5c 100644
--- a/tests/unit/workflows/test_featureInstanceRefMigration.py
+++ b/tests/unit/workflows/test_featureInstanceRefMigration.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Phase-5 Schicht-4 — unit tests for ``materializeFeatureInstanceRefs`` and the
runtime envelope unwrap in ``graphUtils.resolveParameterReferences``.
diff --git a/tests/unit/workflows/test_parameterValidation.py b/tests/unit/workflows/test_parameterValidation.py
index 62799cd1..149e5c72 100644
--- a/tests/unit/workflows/test_parameterValidation.py
+++ b/tests/unit/workflows/test_parameterValidation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unit tests: universal action parameter validation + coercion.
diff --git a/tests/unit/workflows/test_state_management.py b/tests/unit/workflows/test_state_management.py
index ae502397..2162e297 100644
--- a/tests/unit/workflows/test_state_management.py
+++ b/tests/unit/workflows/test_state_management.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unit tests for workflow state management in ChatWorkflow and TaskContext
diff --git a/tests/unit/workflows/test_trigger_executor.py b/tests/unit/workflows/test_trigger_executor.py
index 96a0bf68..9d1ec1e0 100644
--- a/tests/unit/workflows/test_trigger_executor.py
+++ b/tests/unit/workflows/test_trigger_executor.py
@@ -1,4 +1,5 @@
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""TriggerExecutor: form start output must match FormPayload (payload.* refs)."""
import pytest
diff --git a/tests/validation/test_architecture_validation.py b/tests/validation/test_architecture_validation.py
index 09f6e92c..89f2855e 100644
--- a/tests/validation/test_architecture_validation.py
+++ b/tests/validation/test_architecture_validation.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# Copyright (c) 2025 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
End-to-End Validation Tests for New Architecture
diff --git a/tests/validation/test_featureCatalogLabels_i18n.py b/tests/validation/test_featureCatalogLabels_i18n.py
index d6787c43..ffdf1c2b 100644
--- a/tests/validation/test_featureCatalogLabels_i18n.py
+++ b/tests/validation/test_featureCatalogLabels_i18n.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Patrick Motsch
+# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Validation: every label in feature ``main*.py`` catalog lists must be wrapped in ``t(...)``.
From ebc4b2a08093eefc043baa8adcb7f621f67ac59b Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 9 Jun 2026 09:58:05 +0200
Subject: [PATCH 13/16] cp adapted to 2026 poweron 2
---
demoData/neutralizer/_generateTenantDossierPdf.py | 2 ++
demoData/pwg/_generateScans.py | 2 ++
modules/connectors/connectorOerebWfs.py | 2 ++
modules/connectors/connectorSwissTopoMapServer.py | 2 ++
modules/connectors/connectorZhWfsParcels.py | 2 ++
modules/datamodels/jsonContinuation.py | 2 ++
modules/dbHelpers/__init__.py | 2 ++
modules/demoConfigs/__init__.py | 2 ++
modules/demoConfigs/baseDemoConfig.py | 2 ++
modules/demoConfigs/investorDemo2026.py | 2 ++
modules/demoConfigs/pwgDemo2026.py | 2 ++
modules/features/commcoach/__init__.py | 2 ++
modules/features/commcoach/tests/__init__.py | 2 ++
modules/features/realEstate/__init__.py | 2 ++
modules/features/realEstate/bzoDocumentRetriever.py | 2 ++
modules/features/realEstate/bzoExtraction.py | 2 ++
modules/features/realEstate/bzoPdfExtractor.py | 2 ++
modules/features/realEstate/bzoRuleTaxonomy.py | 2 ++
modules/features/realEstate/datamodelFeatureRealEstate.py | 2 ++
modules/features/realEstate/handlerRealEstate.py | 2 ++
modules/features/realEstate/interfaceFeatureRealEstate.py | 2 ++
modules/features/realEstate/mainRealEstate.py | 2 ++
modules/features/realEstate/parcelSelectionService.py | 2 ++
modules/features/realEstate/realEstateGemeindeService.py | 2 ++
modules/features/realEstate/routeFeatureRealEstate.py | 2 ++
modules/features/realEstate/scrapeSwissTopo.py | 2 ++
modules/features/realEstate/serviceAiIntent.py | 2 ++
modules/features/realEstate/serviceBzo.py | 2 ++
modules/features/realEstate/serviceGeometry.py | 2 ++
modules/routes/routeAdminDemoConfig.py | 2 ++
.../services/serviceKnowledge/_progressMessages.py | 2 ++
.../services/serviceKnowledge/subConnectorPrefs.py | 2 ++
modules/serviceCenter/services/serviceSubscription/__init__.py | 2 ++
modules/workflowAutomation/__init__.py | 2 ++
modules/workflowAutomation/editor/__init__.py | 2 ++
scripts/_archive/check_orphan_featureinstance.py | 2 ++
scripts/_archive/i18n_rekey_plaintext_keys.py | 2 ++
scripts/_archive/migrate_async_to_sync.py | 2 ++
scripts/_archive/script_db_cleanup_duplicate_roles.py | 2 ++
scripts/_archive/script_db_migrate_accessrules_objectkeys.py | 2 ++
scripts/check_db_no_sysadmin_role.py | 2 ++
scripts/check_no_sysadmin_role.py | 2 ++
scripts/debug_rag_job_result.py | 2 ++
scripts/exportDbSchemaFromModels.py | 2 ++
scripts/script_db_adapt_to_models.py | 2 ++
scripts/script_db_audit_legacy_state.py | 2 ++
scripts/script_db_init_automation2.py | 2 ++
scripts/script_db_migrate_backgroundjob_progress_data.py | 2 ++
scripts/script_db_migrate_datasource_inherit.py | 2 ++
scripts/script_db_migrate_datasource_rag.py | 2 ++
scripts/script_db_migrate_datasource_settings.py | 2 ++
scripts/script_export_accessrules.py | 2 ++
scripts/script_migrate_user_uid.py | 2 ++
scripts/stage0_filefolder_schema_check.py | 2 ++
tests/demo/__init__.py | 2 ++
tests/demo/conftest.py | 2 ++
tests/demo/test_demo_api.py | 2 ++
tests/demo/test_demo_bootstrap.py | 2 ++
tests/demo/test_demo_data_files.py | 2 ++
tests/demo/test_demo_neutralization.py | 2 ++
tests/demo/test_demo_uc1_trustee.py | 2 ++
tests/demo/test_demo_uc2_realestate.py | 2 ++
tests/demo/test_demo_uc4_i18n.py | 2 ++
tests/fixtures/__init__.py | 2 ++
tests/integration/mandates/__init__.py | 2 ++
tests/integration/users/__init__.py | 2 ++
tests/serviceAi/__init__.py | 2 ++
tests/serviceGeneration/__init__.py | 2 ++
tests/unit/aicore/__init__.py | 2 ++
tests/unit/bootstrap/__init__.py | 2 ++
tests/unit/connectors/__init__.py | 2 ++
tests/unit/methods/__init__.py | 2 ++
tests/unit/nodeDefinitions/test_usesai_flag.py | 2 ++
tests/unit/serviceAgent/test_udm_agent_tools.py | 2 ++
tests/unit/services/test_buildTree.py | 2 ++
tests/unit/services/test_costEstimate.py | 2 ++
tests/unit/services/test_inheritFlags.py | 2 ++
tests/unit/services/test_p1d_consent_prefs.py | 2 ++
tests/unit/services/test_ragLimits.py | 2 ++
tests/unit/services/test_udbNodes.py | 2 ++
tests/unit/teamsbot/__init__.py | 2 ++
tests/unit/workflow/test_extract_content_handover.py | 2 ++
tests/unit/workflow/test_merge_context_handover.py | 2 ++
tests/unit/workflow/test_node_combinations.py | 2 ++
tests/unit/workflow/test_phase3_context_node.py | 2 ++
tests/unit/workflow/test_phase4_workflow_nodes.py | 2 ++
tests/unit/workflow/test_phase5_highvol.py | 2 ++
tests/unit/workflows/test_automation2_graphUtils.py | 2 ++
88 files changed, 176 insertions(+)
diff --git a/demoData/neutralizer/_generateTenantDossierPdf.py b/demoData/neutralizer/_generateTenantDossierPdf.py
index 2d4f5a02..f559451b 100644
--- a/demoData/neutralizer/_generateTenantDossierPdf.py
+++ b/demoData/neutralizer/_generateTenantDossierPdf.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Generate tenant-dossier.pdf for neutralization demo. Run: python _generateTenantDossierPdf.py
Uses ReportLab so the PDF opens reliably in all viewers (stdlib-only PDFs are fragile).
diff --git a/demoData/pwg/_generateScans.py b/demoData/pwg/_generateScans.py
index c93eda55..64125068 100644
--- a/demoData/pwg/_generateScans.py
+++ b/demoData/pwg/_generateScans.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Generate the 3 fictitious PWG scan PDFs used by the pilot demo.
Run: python _generateScans.py
diff --git a/modules/connectors/connectorOerebWfs.py b/modules/connectors/connectorOerebWfs.py
index 62b0ee18..c3cc6de5 100644
--- a/modules/connectors/connectorOerebWfs.py
+++ b/modules/connectors/connectorOerebWfs.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
ÖREB WFS Connector
diff --git a/modules/connectors/connectorSwissTopoMapServer.py b/modules/connectors/connectorSwissTopoMapServer.py
index d7b7a91e..a2e5db04 100644
--- a/modules/connectors/connectorSwissTopoMapServer.py
+++ b/modules/connectors/connectorSwissTopoMapServer.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Swiss Topo MapServer Connector (Simplified)
diff --git a/modules/connectors/connectorZhWfsParcels.py b/modules/connectors/connectorZhWfsParcels.py
index 066c1727..1d85c9a8 100644
--- a/modules/connectors/connectorZhWfsParcels.py
+++ b/modules/connectors/connectorZhWfsParcels.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Swiss Parcel (Liegenschaften) Connector
diff --git a/modules/datamodels/jsonContinuation.py b/modules/datamodels/jsonContinuation.py
index d4ee81f9..ed73ceea 100644
--- a/modules/datamodels/jsonContinuation.py
+++ b/modules/datamodels/jsonContinuation.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
JSON Continuation Context Module
diff --git a/modules/dbHelpers/__init__.py b/modules/dbHelpers/__init__.py
index e69de29b..06003961 100644
--- a/modules/dbHelpers/__init__.py
+++ b/modules/dbHelpers/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/modules/demoConfigs/__init__.py b/modules/demoConfigs/__init__.py
index 6ac5054f..aacdc133 100644
--- a/modules/demoConfigs/__init__.py
+++ b/modules/demoConfigs/__init__.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Demo Configs — Auto-Discovery Module
diff --git a/modules/demoConfigs/baseDemoConfig.py b/modules/demoConfigs/baseDemoConfig.py
index 604c7a78..22a43dc6 100644
--- a/modules/demoConfigs/baseDemoConfig.py
+++ b/modules/demoConfigs/baseDemoConfig.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Base class for demo configurations.
diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py
index e88ce6c7..7efcb3e4 100644
--- a/modules/demoConfigs/investorDemo2026.py
+++ b/modules/demoConfigs/investorDemo2026.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Investor Demo April 2026
diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py
index 90e3c3e4..c796d302 100644
--- a/modules/demoConfigs/pwgDemo2026.py
+++ b/modules/demoConfigs/pwgDemo2026.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""PWG Pilot Demo (April 2026)
Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install:
diff --git a/modules/features/commcoach/__init__.py b/modules/features/commcoach/__init__.py
index ea99083a..1130b94a 100644
--- a/modules/features/commcoach/__init__.py
+++ b/modules/features/commcoach/__init__.py
@@ -1 +1,3 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# CommCoach Feature Container
diff --git a/modules/features/commcoach/tests/__init__.py b/modules/features/commcoach/tests/__init__.py
index e69de29b..06003961 100644
--- a/modules/features/commcoach/tests/__init__.py
+++ b/modules/features/commcoach/tests/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/modules/features/realEstate/__init__.py b/modules/features/realEstate/__init__.py
index 48368b52..9da5534e 100644
--- a/modules/features/realEstate/__init__.py
+++ b/modules/features/realEstate/__init__.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Real Estate feature module.
"""
diff --git a/modules/features/realEstate/bzoDocumentRetriever.py b/modules/features/realEstate/bzoDocumentRetriever.py
index 9b271cda..cc5659d8 100644
--- a/modules/features/realEstate/bzoDocumentRetriever.py
+++ b/modules/features/realEstate/bzoDocumentRetriever.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Document retriever for BZO extraction pipeline.
Queries Dokument table and retrieves PDF content from ComponentObjects.
diff --git a/modules/features/realEstate/bzoExtraction.py b/modules/features/realEstate/bzoExtraction.py
index 3eace0f2..27ab6966 100644
--- a/modules/features/realEstate/bzoExtraction.py
+++ b/modules/features/realEstate/bzoExtraction.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Pipeline for extracting structured content from BZO PDFs.
diff --git a/modules/features/realEstate/bzoPdfExtractor.py b/modules/features/realEstate/bzoPdfExtractor.py
index 155f5406..374f6d5c 100644
--- a/modules/features/realEstate/bzoPdfExtractor.py
+++ b/modules/features/realEstate/bzoPdfExtractor.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
PDF extraction module for BZO documents.
Extracts page-aware text blocks from PDF files.
diff --git a/modules/features/realEstate/bzoRuleTaxonomy.py b/modules/features/realEstate/bzoRuleTaxonomy.py
index dffd824d..583cbf32 100644
--- a/modules/features/realEstate/bzoRuleTaxonomy.py
+++ b/modules/features/realEstate/bzoRuleTaxonomy.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Rule taxonomy for BZO extraction.
Defines fixed rule types and their patterns for deterministic rule detection.
diff --git a/modules/features/realEstate/datamodelFeatureRealEstate.py b/modules/features/realEstate/datamodelFeatureRealEstate.py
index 8de665de..346e998b 100644
--- a/modules/features/realEstate/datamodelFeatureRealEstate.py
+++ b/modules/features/realEstate/datamodelFeatureRealEstate.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Real Estate data models for Architektur-Planungs-App.
Implements a general Swiss architecture planning data model.
diff --git a/modules/features/realEstate/handlerRealEstate.py b/modules/features/realEstate/handlerRealEstate.py
index e08ff6aa..86b765cd 100644
--- a/modules/features/realEstate/handlerRealEstate.py
+++ b/modules/features/realEstate/handlerRealEstate.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Handler functions for Real Estate feature routes.
Contains extracted business logic from route handlers.
diff --git a/modules/features/realEstate/interfaceFeatureRealEstate.py b/modules/features/realEstate/interfaceFeatureRealEstate.py
index 9219a842..24fe4955 100644
--- a/modules/features/realEstate/interfaceFeatureRealEstate.py
+++ b/modules/features/realEstate/interfaceFeatureRealEstate.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Interface to Real Estate database objects.
Uses PostgreSQL connector for data access with user/mandate filtering.
diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py
index b9af4827..0fbbe363 100644
--- a/modules/features/realEstate/mainRealEstate.py
+++ b/modules/features/realEstate/mainRealEstate.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Real Estate feature main entry point.
Handles feature definition, RBAC registration, and lifecycle hooks.
diff --git a/modules/features/realEstate/parcelSelectionService.py b/modules/features/realEstate/parcelSelectionService.py
index c83efbe3..fde19efe 100644
--- a/modules/features/realEstate/parcelSelectionService.py
+++ b/modules/features/realEstate/parcelSelectionService.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Parcel selection service: compute combined outline, total area, and Bauzone grouping.
Used for multi-parcel selection in PEK map view.
diff --git a/modules/features/realEstate/realEstateGemeindeService.py b/modules/features/realEstate/realEstateGemeindeService.py
index bd1b0f9f..53eddb2c 100644
--- a/modules/features/realEstate/realEstateGemeindeService.py
+++ b/modules/features/realEstate/realEstateGemeindeService.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Gemeinde and BZO document services for Real Estate feature.
Provides ensure/import logic used by both routes and extract_bzo_information.
diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py
index b78ee2ee..5723ab39 100644
--- a/modules/features/realEstate/routeFeatureRealEstate.py
+++ b/modules/features/realEstate/routeFeatureRealEstate.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Real Estate routes for the backend API.
Implements stateless endpoints for real estate database operations with AI-powered natural language processing.
diff --git a/modules/features/realEstate/scrapeSwissTopo.py b/modules/features/realEstate/scrapeSwissTopo.py
index 7f7d54e7..ff9b1c3c 100644
--- a/modules/features/realEstate/scrapeSwissTopo.py
+++ b/modules/features/realEstate/scrapeSwissTopo.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Swiss Topo Scraping Script
diff --git a/modules/features/realEstate/serviceAiIntent.py b/modules/features/realEstate/serviceAiIntent.py
index d790d7c8..98745fc1 100644
--- a/modules/features/realEstate/serviceAiIntent.py
+++ b/modules/features/realEstate/serviceAiIntent.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Real Estate feature — AI-based intent recognition and CRUD operations.
diff --git a/modules/features/realEstate/serviceBzo.py b/modules/features/realEstate/serviceBzo.py
index f4ec90bd..f70a799b 100644
--- a/modules/features/realEstate/serviceBzo.py
+++ b/modules/features/realEstate/serviceBzo.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Real Estate feature — BZO (Bau- und Zonenordnung) information extraction.
diff --git a/modules/features/realEstate/serviceGeometry.py b/modules/features/realEstate/serviceGeometry.py
index c8021701..0d29e99f 100644
--- a/modules/features/realEstate/serviceGeometry.py
+++ b/modules/features/realEstate/serviceGeometry.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Real Estate feature — Geometry utilities.
diff --git a/modules/routes/routeAdminDemoConfig.py b/modules/routes/routeAdminDemoConfig.py
index 0673c299..7c750977 100644
--- a/modules/routes/routeAdminDemoConfig.py
+++ b/modules/routes/routeAdminDemoConfig.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Admin Demo Config API
diff --git a/modules/serviceCenter/services/serviceKnowledge/_progressMessages.py b/modules/serviceCenter/services/serviceKnowledge/_progressMessages.py
index 99d91d6b..75f6413e 100644
--- a/modules/serviceCenter/services/serviceKnowledge/_progressMessages.py
+++ b/modules/serviceCenter/services/serviceKnowledge/_progressMessages.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Central i18n registration for BackgroundJob progress messages.
Walkers and consumers report progress via ``progressCb(..., messageKey="…",
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorPrefs.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorPrefs.py
index 4aaaa9bf..29daff58 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorPrefs.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorPrefs.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Per-connection knowledge ingestion preference helpers.
Walkers call `loadConnectionPrefs(connectionId)` once at bootstrap start and
diff --git a/modules/serviceCenter/services/serviceSubscription/__init__.py b/modules/serviceCenter/services/serviceSubscription/__init__.py
index e69de29b..06003961 100644
--- a/modules/serviceCenter/services/serviceSubscription/__init__.py
+++ b/modules/serviceCenter/services/serviceSubscription/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/modules/workflowAutomation/__init__.py b/modules/workflowAutomation/__init__.py
index e6472791..20498d36 100644
--- a/modules/workflowAutomation/__init__.py
+++ b/modules/workflowAutomation/__init__.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
workflowAutomation — System component for workflow orchestration.
diff --git a/modules/workflowAutomation/editor/__init__.py b/modules/workflowAutomation/editor/__init__.py
index 471ba8a5..43e07c7b 100644
--- a/modules/workflowAutomation/editor/__init__.py
+++ b/modules/workflowAutomation/editor/__init__.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
workflowAutomation.editor — Graph/Flow authoring backend.
diff --git a/scripts/_archive/check_orphan_featureinstance.py b/scripts/_archive/check_orphan_featureinstance.py
index c09de61b..13757445 100644
--- a/scripts/_archive/check_orphan_featureinstance.py
+++ b/scripts/_archive/check_orphan_featureinstance.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Quick-Check: existiert FeatureInstance-Row 6019e7d0-b23d-41ec-b9f7-3dd1293078f2
in poweron_app, und welche Mandate/Instances stehen mit dem RedmineTicketMirror in Verbindung?
diff --git a/scripts/_archive/i18n_rekey_plaintext_keys.py b/scripts/_archive/i18n_rekey_plaintext_keys.py
index cf0e7362..5a1de579 100644
--- a/scripts/_archive/i18n_rekey_plaintext_keys.py
+++ b/scripts/_archive/i18n_rekey_plaintext_keys.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Rekey frontend t('dot.notation') -> t('Deutscher Klartext') using locales/de.ts mapping.
diff --git a/scripts/_archive/migrate_async_to_sync.py b/scripts/_archive/migrate_async_to_sync.py
index 8b5626df..2a6239cc 100644
--- a/scripts/_archive/migrate_async_to_sync.py
+++ b/scripts/_archive/migrate_async_to_sync.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Migration Script: Convert async def → def for route handlers that don't need async.
diff --git a/scripts/_archive/script_db_cleanup_duplicate_roles.py b/scripts/_archive/script_db_cleanup_duplicate_roles.py
index 1ded5a51..9a0abdf5 100644
--- a/scripts/_archive/script_db_cleanup_duplicate_roles.py
+++ b/scripts/_archive/script_db_cleanup_duplicate_roles.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Cleanup script for duplicate roles in the database.
diff --git a/scripts/_archive/script_db_migrate_accessrules_objectkeys.py b/scripts/_archive/script_db_migrate_accessrules_objectkeys.py
index b0b5ce4a..156d6398 100644
--- a/scripts/_archive/script_db_migrate_accessrules_objectkeys.py
+++ b/scripts/_archive/script_db_migrate_accessrules_objectkeys.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# -*- coding: utf-8 -*-
"""
Migration Script: Migrate AccessRules to Vollqualifizierte ObjectKeys
diff --git a/scripts/check_db_no_sysadmin_role.py b/scripts/check_db_no_sysadmin_role.py
index e0d02d7c..b15c6ab9 100644
--- a/scripts/check_db_no_sysadmin_role.py
+++ b/scripts/check_db_no_sysadmin_role.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Runtime-Check (A5): bestaetigt, dass die ``sysadmin``-Rolle aus der
Datenbank entfernt wurde und liefert eine kurze Inventur fuer die
isPlatformAdmin / isSysAdmin Flags.
diff --git a/scripts/check_no_sysadmin_role.py b/scripts/check_no_sysadmin_role.py
index 23fa8f2e..7af66465 100644
--- a/scripts/check_no_sysadmin_role.py
+++ b/scripts/check_no_sysadmin_role.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""CI-Gate: Stelle sicher, dass keine Verweise auf die abgeschaffte
``sysadmin``-Rolle bzw. die alten Helper im Codebase mehr existieren.
diff --git a/scripts/debug_rag_job_result.py b/scripts/debug_rag_job_result.py
index c107f21e..5742f0b0 100644
--- a/scripts/debug_rag_job_result.py
+++ b/scripts/debug_rag_job_result.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Diagnose: read a connection.bootstrap job result and print its keys.
Usage (from repo root):
diff --git a/scripts/exportDbSchemaFromModels.py b/scripts/exportDbSchemaFromModels.py
index dc3e4ab8..abd23762 100644
--- a/scripts/exportDbSchemaFromModels.py
+++ b/scripts/exportDbSchemaFromModels.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Export the database schema from Pydantic MODEL_REGISTRY + fk_target metadata.
Usage (run from gateway/):
diff --git a/scripts/script_db_adapt_to_models.py b/scripts/script_db_adapt_to_models.py
index 6e5ca7a3..3811b029 100644
--- a/scripts/script_db_adapt_to_models.py
+++ b/scripts/script_db_adapt_to_models.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Datenbank-Anpassung an Pydantic-Modelle.
diff --git a/scripts/script_db_audit_legacy_state.py b/scripts/script_db_audit_legacy_state.py
index 54ee6474..c6a59fc1 100644
--- a/scripts/script_db_audit_legacy_state.py
+++ b/scripts/script_db_audit_legacy_state.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Audit-Skript fuer Legacy-Bestaende vor Bootstrap-Cleanup (Plan C).
Prueft fuer jede der 5 Bootstrap-Migrationsroutinen, ob noch Restbestand
diff --git a/scripts/script_db_init_automation2.py b/scripts/script_db_init_automation2.py
index 56d0daaf..7e9681f6 100644
--- a/scripts/script_db_init_automation2.py
+++ b/scripts/script_db_init_automation2.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Initialize poweron_automation2 database for the Automation2 feature.
diff --git a/scripts/script_db_migrate_backgroundjob_progress_data.py b/scripts/script_db_migrate_backgroundjob_progress_data.py
index bc5fc348..0e7b4cc8 100644
--- a/scripts/script_db_migrate_backgroundjob_progress_data.py
+++ b/scripts/script_db_migrate_backgroundjob_progress_data.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Migration: Add `progressMessageData` JSONB column to BackgroundJob.
Carries the structured i18n payload that lets the frontend translate
diff --git a/scripts/script_db_migrate_datasource_inherit.py b/scripts/script_db_migrate_datasource_inherit.py
index 3444cbee..014ef01d 100644
--- a/scripts/script_db_migrate_datasource_inherit.py
+++ b/scripts/script_db_migrate_datasource_inherit.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Migration: Drop NOT NULL on DataSource/FeatureDataSource cascade-inherit flags.
Switches three-valued semantics (NULL = inherit, True/False = explicit) for:
diff --git a/scripts/script_db_migrate_datasource_rag.py b/scripts/script_db_migrate_datasource_rag.py
index 95c2ae35..9771900f 100644
--- a/scripts/script_db_migrate_datasource_rag.py
+++ b/scripts/script_db_migrate_datasource_rag.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Migration: Rename DataSource.autoSync -> ragIndexEnabled, lastSynced -> lastIndexed.
This is a one-off migration for the RAG consent & control unification.
diff --git a/scripts/script_db_migrate_datasource_settings.py b/scripts/script_db_migrate_datasource_settings.py
index 9e821221..10a6238f 100644
--- a/scripts/script_db_migrate_datasource_settings.py
+++ b/scripts/script_db_migrate_datasource_settings.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Migration: Add `settings` JSONB column to DataSource and FeatureDataSource.
This is a one-off migration for the UDB DataSource Settings (Settings-Icon)
diff --git a/scripts/script_export_accessrules.py b/scripts/script_export_accessrules.py
index 6d5aeec7..2ba9bf64 100644
--- a/scripts/script_export_accessrules.py
+++ b/scripts/script_export_accessrules.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# -*- coding: utf-8 -*-
"""
Export Script: Generate Access Rules per Role Report
diff --git a/scripts/script_migrate_user_uid.py b/scripts/script_migrate_user_uid.py
index e36f483d..eba0a4a5 100644
--- a/scripts/script_migrate_user_uid.py
+++ b/scripts/script_migrate_user_uid.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""One-time migration: Reassign all DB references from an old user UID to a new UID.
When a user is re-created in PORTA (same username, new UUID), all existing records
diff --git a/scripts/stage0_filefolder_schema_check.py b/scripts/stage0_filefolder_schema_check.py
index d172e19c..33f89c7e 100644
--- a/scripts/stage0_filefolder_schema_check.py
+++ b/scripts/stage0_filefolder_schema_check.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Stage 0: verify FileFolder table + FileItem.folderId column in management DB.
Run from the gateway directory (same as uvicorn):
diff --git a/tests/demo/__init__.py b/tests/demo/__init__.py
index e69de29b..06003961 100644
--- a/tests/demo/__init__.py
+++ b/tests/demo/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/demo/conftest.py b/tests/demo/conftest.py
index 43d8363e..b01680f2 100644
--- a/tests/demo/conftest.py
+++ b/tests/demo/conftest.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Demo test fixtures.
diff --git a/tests/demo/test_demo_api.py b/tests/demo/test_demo_api.py
index 1973d110..4d5bc7c0 100644
--- a/tests/demo/test_demo_api.py
+++ b/tests/demo/test_demo_api.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
T-API: Demo Config API endpoint verification.
diff --git a/tests/demo/test_demo_bootstrap.py b/tests/demo/test_demo_bootstrap.py
index 45db18c7..a3f4f5d3 100644
--- a/tests/demo/test_demo_bootstrap.py
+++ b/tests/demo/test_demo_bootstrap.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
T-BOOT: Bootstrap idempotency and demo state verification.
diff --git a/tests/demo/test_demo_data_files.py b/tests/demo/test_demo_data_files.py
index 4e7a3d40..40681cbe 100644
--- a/tests/demo/test_demo_data_files.py
+++ b/tests/demo/test_demo_data_files.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
T-DATA: Demo data files verification.
diff --git a/tests/demo/test_demo_neutralization.py b/tests/demo/test_demo_neutralization.py
index aca54491..ff302a52 100644
--- a/tests/demo/test_demo_neutralization.py
+++ b/tests/demo/test_demo_neutralization.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
T-NEU: Neutralization config verification.
diff --git a/tests/demo/test_demo_uc1_trustee.py b/tests/demo/test_demo_uc1_trustee.py
index f7fd2ce0..920ecfb7 100644
--- a/tests/demo/test_demo_uc1_trustee.py
+++ b/tests/demo/test_demo_uc1_trustee.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
T-UC1: Trustee — Spesenverarbeitung.
diff --git a/tests/demo/test_demo_uc2_realestate.py b/tests/demo/test_demo_uc2_realestate.py
index 0d91122e..5205234d 100644
--- a/tests/demo/test_demo_uc2_realestate.py
+++ b/tests/demo/test_demo_uc2_realestate.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
T-UC2: Immobilien — Machbarkeitsstudie.
diff --git a/tests/demo/test_demo_uc4_i18n.py b/tests/demo/test_demo_uc4_i18n.py
index 04eba4b9..a9ea2cf2 100644
--- a/tests/demo/test_demo_uc4_i18n.py
+++ b/tests/demo/test_demo_uc4_i18n.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
T-UC4: Sprach-Deployment — Spanish (es).
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
index e69de29b..06003961 100644
--- a/tests/fixtures/__init__.py
+++ b/tests/fixtures/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/integration/mandates/__init__.py b/tests/integration/mandates/__init__.py
index e69de29b..06003961 100644
--- a/tests/integration/mandates/__init__.py
+++ b/tests/integration/mandates/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/integration/users/__init__.py b/tests/integration/users/__init__.py
index e69de29b..06003961 100644
--- a/tests/integration/users/__init__.py
+++ b/tests/integration/users/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/serviceAi/__init__.py b/tests/serviceAi/__init__.py
index e69de29b..06003961 100644
--- a/tests/serviceAi/__init__.py
+++ b/tests/serviceAi/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/serviceGeneration/__init__.py b/tests/serviceGeneration/__init__.py
index e69de29b..06003961 100644
--- a/tests/serviceGeneration/__init__.py
+++ b/tests/serviceGeneration/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/unit/aicore/__init__.py b/tests/unit/aicore/__init__.py
index e69de29b..06003961 100644
--- a/tests/unit/aicore/__init__.py
+++ b/tests/unit/aicore/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/unit/bootstrap/__init__.py b/tests/unit/bootstrap/__init__.py
index e69de29b..06003961 100644
--- a/tests/unit/bootstrap/__init__.py
+++ b/tests/unit/bootstrap/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/unit/connectors/__init__.py b/tests/unit/connectors/__init__.py
index e69de29b..06003961 100644
--- a/tests/unit/connectors/__init__.py
+++ b/tests/unit/connectors/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/unit/methods/__init__.py b/tests/unit/methods/__init__.py
index e69de29b..06003961 100644
--- a/tests/unit/methods/__init__.py
+++ b/tests/unit/methods/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/unit/nodeDefinitions/test_usesai_flag.py b/tests/unit/nodeDefinitions/test_usesai_flag.py
index caf07960..ebbd5444 100644
--- a/tests/unit/nodeDefinitions/test_usesai_flag.py
+++ b/tests/unit/nodeDefinitions/test_usesai_flag.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# T18 — AC #16/#17: meta.usesAi on every node type; AI vs non-AI distinction.
import pytest
diff --git a/tests/unit/serviceAgent/test_udm_agent_tools.py b/tests/unit/serviceAgent/test_udm_agent_tools.py
index 3449dd81..f8e09251 100644
--- a/tests/unit/serviceAgent/test_udm_agent_tools.py
+++ b/tests/unit/serviceAgent/test_udm_agent_tools.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Phase 7: UDM tools (getUdmStructure, walkUdmBlocks, filterUdmByType).
from modules.serviceCenter.services.serviceAgent.coreTools._documentTools import (
diff --git a/tests/unit/services/test_buildTree.py b/tests/unit/services/test_buildTree.py
index 1f8c8da0..d99a99c8 100644
--- a/tests/unit/services/test_buildTree.py
+++ b/tests/unit/services/test_buildTree.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Unit tests for the generic UDB tree builder (`_buildTree.py`).
Most node-level behavior moved into the polymorphic class hierarchy
diff --git a/tests/unit/services/test_costEstimate.py b/tests/unit/services/test_costEstimate.py
index a8e25138..8b913bea 100644
--- a/tests/unit/services/test_costEstimate.py
+++ b/tests/unit/services/test_costEstimate.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Unit tests for `_costEstimate` heuristic.
Validates the output shape, basic formulas, and that 'basis' annotations
diff --git a/tests/unit/services/test_inheritFlags.py b/tests/unit/services/test_inheritFlags.py
index a74f1f7f..be3b41cf 100644
--- a/tests/unit/services/test_inheritFlags.py
+++ b/tests/unit/services/test_inheritFlags.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Unit tests for `_inheritFlags` cascade-inherit helpers.
Verifies:
diff --git a/tests/unit/services/test_p1d_consent_prefs.py b/tests/unit/services/test_p1d_consent_prefs.py
index 0d15f546..9300b164 100644
--- a/tests/unit/services/test_p1d_consent_prefs.py
+++ b/tests/unit/services/test_p1d_consent_prefs.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Unit tests for P1d: consent gating, preference parsing, and walker behaviour.
Tests
diff --git a/tests/unit/services/test_ragLimits.py b/tests/unit/services/test_ragLimits.py
index 1ab5c403..ce2a862d 100644
--- a/tests/unit/services/test_ragLimits.py
+++ b/tests/unit/services/test_ragLimits.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Unit tests for `_ragLimits` central helpers.
Verifies:
diff --git a/tests/unit/services/test_udbNodes.py b/tests/unit/services/test_udbNodes.py
index f9fae171..102bba27 100644
--- a/tests/unit/services/test_udbNodes.py
+++ b/tests/unit/services/test_udbNodes.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""Unit tests for the polymorphic UDB node hierarchy (udbNodes.py).
Each concrete node class is exercised for:
diff --git a/tests/unit/teamsbot/__init__.py b/tests/unit/teamsbot/__init__.py
index e69de29b..06003961 100644
--- a/tests/unit/teamsbot/__init__.py
+++ b/tests/unit/teamsbot/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
diff --git a/tests/unit/workflow/test_extract_content_handover.py b/tests/unit/workflow/test_extract_content_handover.py
index 8e8f409c..401b7a16 100644
--- a/tests/unit/workflow/test_extract_content_handover.py
+++ b/tests/unit/workflow/test_extract_content_handover.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Unit tests: context.extractContent serialize + presentation helpers (legacy handover dicts vs new paths).
import base64
diff --git a/tests/unit/workflow/test_merge_context_handover.py b/tests/unit/workflow/test_merge_context_handover.py
index 30a60b8f..d62fcc12 100644
--- a/tests/unit/workflow/test_merge_context_handover.py
+++ b/tests/unit/workflow/test_merge_context_handover.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Unit tests: context.mergeContext primary text from extract handover (documents[0]).
import json
diff --git a/tests/unit/workflow/test_node_combinations.py b/tests/unit/workflow/test_node_combinations.py
index b4857a14..3c807f19 100644
--- a/tests/unit/workflow/test_node_combinations.py
+++ b/tests/unit/workflow/test_node_combinations.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Tests: node handover compatibility across all major node combinations.
#
# Covers:
diff --git a/tests/unit/workflow/test_phase3_context_node.py b/tests/unit/workflow/test_phase3_context_node.py
index 5f113d5e..56ed0df3 100644
--- a/tests/unit/workflow/test_phase3_context_node.py
+++ b/tests/unit/workflow/test_phase3_context_node.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Tests for Phase 3: context.extractContent node, port types, executor dispatch.
import pytest
diff --git a/tests/unit/workflow/test_phase4_workflow_nodes.py b/tests/unit/workflow/test_phase4_workflow_nodes.py
index 3ca0792d..e042d85d 100644
--- a/tests/unit/workflow/test_phase4_workflow_nodes.py
+++ b/tests/unit/workflow/test_phase4_workflow_nodes.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Tests for Phase 4: data.consolidate, ai.consolidate, flow.loop level/concurrency, flow.merge dynamic.
import pytest
diff --git a/tests/unit/workflow/test_phase5_highvol.py b/tests/unit/workflow/test_phase5_highvol.py
index 44c51d76..287c96e5 100644
--- a/tests/unit/workflow/test_phase5_highvol.py
+++ b/tests/unit/workflow/test_phase5_highvol.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
# Tests for Phase 5: Loop concurrency, StepLog batching, streaming aggregate.
import pytest
diff --git a/tests/unit/workflows/test_automation2_graphUtils.py b/tests/unit/workflows/test_automation2_graphUtils.py
index 179857c1..941880d7 100644
--- a/tests/unit/workflows/test_automation2_graphUtils.py
+++ b/tests/unit/workflows/test_automation2_graphUtils.py
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
"""
Unit tests for automation2 graphUtils - resolveParameterReferences (ref/value format).
"""
From 06e68c343b01b69c9fe0434b6f26e1b2636311ed Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 9 Jun 2026 22:59:26 +0200
Subject: [PATCH 14/16] automation fixes
---
app.py | 2 +
modules/datamodels/datamodelNavigation.py | 51 ++--------
modules/dbHelpers/fkLabelResolver.py | 18 ++++
.../realEstate/routeFeatureRealEstate.py | 18 +++-
.../features/trustee/routeFeatureTrustee.py | 38 ++++---
modules/routes/routeAdminFeatures.py | 7 +-
modules/routes/routeAdminRbacRules.py | 5 +-
modules/routes/routeAudit.py | 10 ++
modules/routes/routeDataPrompts.py | 2 +-
modules/routes/routeSubscription.py | 4 +
modules/routes/routeWorkflowAutomation.py | 98 ++++++++++++++++++-
.../services/serviceChat/mainServiceChat.py | 3 +
.../services/serviceKnowledge/udbNodes.py | 2 +-
.../engine/executionEngine.py | 2 +-
.../engine/executors/actionNodeExecutor.py | 16 +--
.../methodContext/actions/extractContent.py | 14 +--
tests/unit/services/test_inheritFlags.py | 2 +-
17 files changed, 207 insertions(+), 85 deletions(-)
diff --git a/app.py b/app.py
index 68341361..55cc7fc0 100644
--- a/app.py
+++ b/app.py
@@ -501,6 +501,8 @@ async def lifespan(app: FastAPI):
return
if isinstance(exc, ConnectionAbortedError):
return
+ if exc and "LocalProtocolError" in type(exc).__name__:
+ return
loop.default_exception_handler(ctx)
main_loop.set_exception_handler(_suppressClientDisconnect)
except RuntimeError:
diff --git a/modules/datamodels/datamodelNavigation.py b/modules/datamodels/datamodelNavigation.py
index 5c40a165..101cef99 100644
--- a/modules/datamodels/datamodelNavigation.py
+++ b/modules/datamodels/datamodelNavigation.py
@@ -150,52 +150,21 @@ NAVIGATION_SECTIONS = [
},
],
},
- # --- Workflow-Automation (System-Komponente, cross-mandate) ---
+ # --- Solution Design (System-Komponente, cross-mandate) ---
+ # Single nav entry; tabs are managed internally by WorkflowAutomationHubPage.
{
"id": "workflowAutomation",
- "title": t("Workflow-Automation"),
+ "title": t("Lösungsdesign"),
"order": 25,
"items": [
{
- "id": "wa-workflows",
- "objectKey": "ui.system.workflowAutomation.workflows",
- "label": t("Workflows"),
+ "id": "wa-hub",
+ "objectKey": "ui.system.workflowAutomation",
+ "label": t("Workflow-Automation"),
"icon": "FaSitemap",
- "path": "/workflow-automation?tab=workflows",
+ "path": "/workflow-automation",
"order": 10,
},
- {
- "id": "wa-editor",
- "objectKey": "ui.system.workflowAutomation.editor",
- "label": t("Editor"),
- "icon": "FaProjectDiagram",
- "path": "/workflow-automation?tab=editor",
- "order": 20,
- },
- {
- "id": "wa-templates",
- "objectKey": "ui.system.workflowAutomation.templates",
- "label": t("Vorlagen"),
- "icon": "FaCopy",
- "path": "/workflow-automation?tab=templates",
- "order": 30,
- },
- {
- "id": "wa-runs",
- "objectKey": "ui.system.workflowAutomation.runs",
- "label": t("Läufe"),
- "icon": "FaPlay",
- "path": "/workflow-automation?tab=runs",
- "order": 40,
- },
- {
- "id": "wa-tasks",
- "objectKey": "ui.system.workflowAutomation.tasks",
- "label": t("Tasks"),
- "icon": "FaTasks",
- "path": "/workflow-automation?tab=tasks",
- "order": 50,
- },
],
},
# --- Administration (with subgroups) ---
@@ -237,7 +206,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-users",
"objectKey": "ui.admin.users",
- "label": t("Benutzer"),
+ "label": t("Übersicht"),
"icon": "FaUsers",
"path": "/admin/users",
"order": 10,
@@ -246,7 +215,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-invitations",
"objectKey": "ui.admin.invitations",
- "label": t("Benutzer-Einladungen"),
+ "label": t("Einladungen"),
"icon": "FaEnvelopeOpenText",
"path": "/admin/invitations",
"order": 20,
@@ -255,7 +224,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-user-access-overview",
"objectKey": "ui.admin.userAccessOverview",
- "label": t("Benutzer-Zugriffsübersicht"),
+ "label": t("Zugriffe"),
"icon": "FaClipboardList",
"path": "/admin/user-access-overview",
"order": 30,
diff --git a/modules/dbHelpers/fkLabelResolver.py b/modules/dbHelpers/fkLabelResolver.py
index 35a673af..e9829001 100644
--- a/modules/dbHelpers/fkLabelResolver.py
+++ b/modules/dbHelpers/fkLabelResolver.py
@@ -96,6 +96,23 @@ def resolveRoleLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
return out
+def resolveFileLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
+ """Resolve FileItem IDs to fileName. Returns None for unresolvable."""
+ if not ids:
+ return {}
+ from modules.datamodels.datamodelFiles import FileItem as _FileItem
+ recs = db.getRecordset(
+ _FileItem,
+ recordFilter={"id": list(set(ids))},
+ ) or []
+ out: Dict[str, Optional[str]] = {i: None for i in ids}
+ for r in recs:
+ fid = r.get("id")
+ if fid:
+ out[fid] = r.get("fileName") or None
+ return out
+
+
# ---------------------------------------------------------------------------
# Resolver registry
# ---------------------------------------------------------------------------
@@ -105,6 +122,7 @@ _BUILTIN_FK_RESOLVERS: Dict[str, Callable] = {
"FeatureInstance": resolveInstanceLabels,
"UserInDB": resolveUserLabels,
"Role": resolveRoleLabels,
+ "FileItem": resolveFileLabels,
}
diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py
index 5723ab39..d32c48cd 100644
--- a/modules/features/realEstate/routeFeatureRealEstate.py
+++ b/modules/features/realEstate/routeFeatureRealEstate.py
@@ -263,7 +263,7 @@ def get_projects(
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
- enrichRowsWithFkLabels(itemDicts, Projekt, db=interface.db)
+ enrichRowsWithFkLabels(itemDicts, Projekt, db=getRootInterface().db)
return handleFilterValuesInMemory(itemDicts, column, pagination)
return handleIdsInMemory(itemDicts, pagination)
@@ -271,7 +271,9 @@ def get_projects(
paginationParams = _parsePagination(pagination)
if paginationParams:
from modules.dbHelpers.paginationHelpers import applyFiltersAndSort
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
+ enrichRowsWithFkLabels(itemDicts, Projekt, db=getRootInterface().db)
filtered = applyFiltersAndSort(itemDicts, paginationParams)
total_items = len(filtered)
total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize
@@ -289,7 +291,10 @@ def get_projects(
filters=paginationParams.filters
)
)
- return PaginatedResponse(items=items, pagination=None)
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
+ itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
+ enrichRowsWithFkLabels(itemDicts, Projekt, db=getRootInterface().db)
+ return PaginatedResponse(items=itemDicts, pagination=None)
@router.get("/{instanceId}/projects/{projectId}", response_model=Projekt)
@@ -405,7 +410,7 @@ def get_parcels(
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
- enrichRowsWithFkLabels(itemDicts, Parzelle, db=interface.db)
+ enrichRowsWithFkLabels(itemDicts, Parzelle, db=getRootInterface().db)
return handleFilterValuesInMemory(itemDicts, column, pagination)
return handleIdsInMemory(itemDicts, pagination)
@@ -413,7 +418,9 @@ def get_parcels(
paginationParams = _parsePagination(pagination)
if paginationParams:
from modules.dbHelpers.paginationHelpers import applyFiltersAndSort
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
+ enrichRowsWithFkLabels(itemDicts, Parzelle, db=getRootInterface().db)
filtered = applyFiltersAndSort(itemDicts, paginationParams)
total_items = len(filtered)
total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize
@@ -431,7 +438,10 @@ def get_parcels(
filters=paginationParams.filters
)
)
- return PaginatedResponse(items=items, pagination=None)
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
+ itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
+ enrichRowsWithFkLabels(itemDicts, Parzelle, db=getRootInterface().db)
+ return PaginatedResponse(items=itemDicts, pagination=None)
@router.get("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py
index c06f8604..8b5ba94a 100644
--- a/modules/features/trustee/routeFeatureTrustee.py
+++ b/modules/features/trustee/routeFeatureTrustee.py
@@ -437,7 +437,7 @@ def get_organisations(
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
if paginationParams and hasattr(result, 'items'):
- enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeOrganisation, db=interface.db)
+ enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeOrganisation, db=getRootInterface().db)
return {
"items": enriched,
"pagination": PaginationMetadata(
@@ -450,7 +450,7 @@ def get_organisations(
).model_dump(),
}
items = result if isinstance(result, list) else result.items
- enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeOrganisation, db=interface.db)
+ enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeOrganisation, db=getRootInterface().db)
return {"items": enriched, "pagination": None}
@@ -557,7 +557,7 @@ def get_roles(
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
if paginationParams and hasattr(result, 'items'):
- enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeRole, db=interface.db)
+ enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeRole, db=getRootInterface().db)
return {
"items": enriched,
"pagination": PaginationMetadata(
@@ -570,7 +570,7 @@ def get_roles(
).model_dump(),
}
items = result if isinstance(result, list) else result.items
- enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeRole, db=interface.db)
+ enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeRole, db=getRootInterface().db)
return {"items": enriched, "pagination": None}
@@ -677,7 +677,7 @@ def get_all_access(
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
if paginationParams and hasattr(result, 'items'):
- enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeAccess, db=interface.db)
+ enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeAccess, db=getRootInterface().db)
return {
"items": enriched,
"pagination": PaginationMetadata(
@@ -690,7 +690,7 @@ def get_all_access(
).model_dump(),
}
items = result if isinstance(result, list) else result.items
- enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeAccess, db=interface.db)
+ enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeAccess, db=getRootInterface().db)
return {"items": enriched, "pagination": None}
@@ -827,7 +827,7 @@ def get_contracts(
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
if paginationParams and hasattr(result, 'items'):
- enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeContract, db=interface.db)
+ enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeContract, db=getRootInterface().db)
return {
"items": enriched,
"pagination": PaginationMetadata(
@@ -840,7 +840,7 @@ def get_contracts(
).model_dump(),
}
items = result if isinstance(result, list) else result.items
- enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeContract, db=interface.db)
+ enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeContract, db=getRootInterface().db)
return {"items": enriched, "pagination": None}
@@ -953,6 +953,7 @@ def get_documents(
context: RequestContext = Depends(getRequestContext)
):
"""Get all documents (metadata only) with optional pagination."""
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
mandateId = _validateInstanceAccess(instanceId, context)
if mode in ("filterValues", "ids"):
@@ -966,8 +967,9 @@ def get_documents(
return [r.model_dump() if hasattr(r, 'model_dump') else r for r in items]
if paginationParams and hasattr(result, 'items'):
+ enriched = enrichRowsWithFkLabels(_itemsToDicts(result.items), TrusteeDocument, db=getRootInterface().db)
return {
- "items": _itemsToDicts(result.items),
+ "items": enriched,
"pagination": PaginationMetadata(
currentPage=paginationParams.page or 1,
pageSize=paginationParams.pageSize or 20,
@@ -978,7 +980,8 @@ def get_documents(
).model_dump(),
}
items = result if isinstance(result, list) else result.items
- return {"items": _itemsToDicts(items), "pagination": None}
+ enriched = enrichRowsWithFkLabels(_itemsToDicts(items), TrusteeDocument, db=getRootInterface().db)
+ return {"items": enriched, "pagination": None}
def _handleDocumentMode(instanceId, mandateId, mode, column, pagination, context):
@@ -991,7 +994,7 @@ def _handleDocumentMode(instanceId, mandateId, mode, column, pagination, context
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
result = interface.getAllDocuments(None)
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)]
- enrichRowsWithFkLabels(items, TrusteeDocument, db=interface.db)
+ enrichRowsWithFkLabels(items, TrusteeDocument, db=getRootInterface().db)
return handleFilterValuesInMemory(items, column, pagination)
if mode == "ids":
result = interface.getAllDocuments(None)
@@ -1229,6 +1232,7 @@ def get_positions(
context: RequestContext = Depends(getRequestContext)
):
"""Get all positions with optional pagination."""
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
mandateId = _validateInstanceAccess(instanceId, context)
if mode in ("filterValues", "ids"):
@@ -1241,9 +1245,12 @@ def get_positions(
def _itemsToDicts(items):
return [r.model_dump() if hasattr(r, 'model_dump') else r for r in items]
+ featureResolvers = _buildFeatureInternalResolvers(TrusteePosition, interface.db)
+
if paginationParams and hasattr(result, 'items'):
items = _itemsToDicts(result.items)
_enrichPositionsWithSyncStatus(items, interface, instanceId)
+ enrichRowsWithFkLabels(items, TrusteePosition, db=getRootInterface().db, extraResolvers=featureResolvers or None)
return {
"items": items,
"pagination": PaginationMetadata(
@@ -1258,6 +1265,7 @@ def get_positions(
rawItems = result if isinstance(result, list) else result.items
items = _itemsToDicts(rawItems)
_enrichPositionsWithSyncStatus(items, interface, instanceId)
+ enrichRowsWithFkLabels(items, TrusteePosition, db=getRootInterface().db, extraResolvers=featureResolvers or None)
return {"items": items, "pagination": None}
@@ -1273,7 +1281,7 @@ def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context
result = interface.getAllPositions(None)
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)]
_enrichPositionsWithSyncStatus(items, interface, instanceId)
- enrichRowsWithFkLabels(items, TrusteePositionView, db=interface.db)
+ enrichRowsWithFkLabels(items, TrusteePositionView, db=getRootInterface().db)
return handleFilterValuesInMemory(items, column, pagination)
if mode == "ids":
result = interface.getAllPositions(None)
@@ -2075,7 +2083,7 @@ def _paginatedReadEndpoint(
rawItems = result.items if hasattr(result, "items") else result
items = [r.model_dump() if hasattr(r, "model_dump") else r for r in rawItems]
featureResolvers = _buildFeatureInternalResolvers(modelClass, interface.db)
- enrichRowsWithFkLabels(items, modelClass, db=interface.db, extraResolvers=featureResolvers or None)
+ enrichRowsWithFkLabels(items, modelClass, db=getRootInterface().db, extraResolvers=featureResolvers or None)
return handleFilterValuesInMemory(items, column, pagination)
if mode == "ids":
@@ -2113,7 +2121,7 @@ def _paginatedReadEndpoint(
if paginationParams and hasattr(result, "items"):
enriched = enrichRowsWithFkLabels(
_itemsToDicts(result.items), modelClass,
- db=interface.db, extraResolvers=featureResolvers or None,
+ db=getRootInterface().db, extraResolvers=featureResolvers or None,
)
return {
"items": enriched,
@@ -2129,7 +2137,7 @@ def _paginatedReadEndpoint(
items = result.items if hasattr(result, "items") else result
enriched = enrichRowsWithFkLabels(
_itemsToDicts(items), modelClass,
- db=interface.db, extraResolvers=featureResolvers or None,
+ db=getRootInterface().db, extraResolvers=featureResolvers or None,
)
return {"items": enriched, "pagination": None}
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index 0a9626dc..e8daa385 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -472,12 +472,13 @@ def list_feature_instances(
items = [inst.model_dump() for inst in instances]
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
+ from modules.datamodels.datamodelFeatures import FeatureInstance
+ enrichRowsWithFkLabels(items, FeatureInstance, db=rootInterface.db)
+
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
- from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
- from modules.datamodels.datamodelFeatures import FeatureInstance
- enrichRowsWithFkLabels(items, FeatureInstance, db=rootInterface.db)
return handleFilterValuesInMemory(items, column, pagination)
if mode == "ids":
diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py
index 36577de7..83aaef00 100644
--- a/modules/routes/routeAdminRbacRules.py
+++ b/modules/routes/routeAdminRbacRules.py
@@ -940,6 +940,8 @@ def list_roles(
if paginationParams:
from modules.dbHelpers.paginationHelpers import applyFiltersAndSort
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
+ enrichRowsWithFkLabels(result, Role, db=interface.db)
sortedResult = applyFiltersAndSort(result, paginationParams)
totalItems = len(sortedResult)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
@@ -959,7 +961,8 @@ def list_roles(
)
)
else:
- # No pagination - return all roles
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
+ enrichRowsWithFkLabels(result, Role, db=interface.db)
return PaginatedResponse(
items=result,
pagination=None
diff --git a/modules/routes/routeAudit.py b/modules/routes/routeAudit.py
index 9dfd074d..f6aa16c8 100644
--- a/modules/routes/routeAudit.py
+++ b/modules/routes/routeAudit.py
@@ -363,6 +363,16 @@ async def getNeutralizationMappings(
_enrichUserAndInstanceLabels(items, context)
+ fileIds = list({r.get("fileId") for r in items if r.get("fileId")})
+ if fileIds:
+ from modules.dbHelpers.fkLabelResolver import resolveFileLabels
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ fileMap = resolveFileLabels(getRootInterface().db, fileIds)
+ for r in items:
+ fid = r.get("fileId")
+ if fid and fid in fileMap:
+ r["fileIdLabel"] = fileMap[fid] or fid
+
if mode == "filterValues" and column:
items = _applySortFilterSearch(items, filtersJson=filters)
return _distinctColumnValues(items, column)
diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py
index bbb566e7..164d4233 100644
--- a/modules/routes/routeDataPrompts.py
+++ b/modules/routes/routeDataPrompts.py
@@ -120,7 +120,7 @@ def get_prompts(
def _promptsToEnrichedDicts(promptItems):
dicts = [r.model_dump() if hasattr(r, 'model_dump') else (dict(r) if not isinstance(r, dict) else r) for r in promptItems]
- enrichRowsWithFkLabels(dicts, Prompt, db=managementInterface.db)
+ enrichRowsWithFkLabels(dicts, Prompt, db=getAppInterface(currentUser).db)
return dicts
managementInterface = interfaceDbManagement.getInterface(currentUser)
diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py
index 709d70e5..57a00093 100644
--- a/modules/routes/routeSubscription.py
+++ b/modules/routes/routeSubscription.py
@@ -514,6 +514,10 @@ def getAllSubscriptions(
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
enriched = _buildEnrichedSubscriptions()
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
+ from modules.datamodels.datamodelSubscription import MandateSubscription
+ from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf
+ enrichRowsWithFkLabels(enriched, MandateSubscription, db=_getRootIf().db)
filtered = applyFiltersAndSort(enriched, paginationParams)
if paginationParams:
diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py
index 4fb7cca9..fe3e8853 100644
--- a/modules/routes/routeWorkflowAutomation.py
+++ b/modules/routes/routeWorkflowAutomation.py
@@ -63,6 +63,8 @@ async def _listWorkflows(
pagination: Optional[str] = Query(default=None),
mandateId: Optional[str] = Query(default=None),
):
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
+ from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
db = _getWorkflowAutomationDb()
try:
db._ensureTableExists(AutoWorkflow)
@@ -76,6 +78,7 @@ async def _listWorkflows(
params = _parsePaginationOr400(pagination)
records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter)
+ enrichRowsWithFkLabels(records or [], AutoWorkflow, db=_getRootIface().db)
if params:
filtered = applyFiltersAndSort(records or [], params)
pageItems, totalItems = paginateInMemory(filtered, params)
@@ -169,6 +172,8 @@ async def _listRuns(
mandateId: Optional[str] = Query(default=None),
workflowId: Optional[str] = Query(default=None),
):
+ from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
+ from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
db = _getWorkflowAutomationDb()
try:
db._ensureTableExists(AutoRun)
@@ -185,6 +190,15 @@ async def _listRuns(
params = _parsePaginationOr400(pagination)
records = db.getRecordset(AutoRun, recordFilter=scopeFilter)
+
+ def _resolveWorkflowLabels(ids):
+ wfRecs = db.getRecordset(AutoWorkflow, recordFilter={"id": list(set(ids))}) or []
+ return {r.get("id"): r.get("label") or r.get("name") for r in wfRecs}
+
+ enrichRowsWithFkLabels(
+ records or [], AutoRun, db=_getRootIface().db,
+ extraResolvers={"workflowId": _resolveWorkflowLabels},
+ )
if params:
filtered = applyFiltersAndSort(records or [], params)
pageItems, totalItems = paginateInMemory(filtered, params)
@@ -991,7 +1005,7 @@ def _getMetrics(
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__"}
+ runFilter = {"workflowId": 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:
@@ -1275,7 +1289,8 @@ def _getRunDetail(
if tid:
try:
from modules.dbHelpers.fkLabelResolver import resolveInstanceLabels
- labelMap = resolveInstanceLabels(db, [tid])
+ from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
+ labelMap = resolveInstanceLabels(_getRootIface().db, [tid])
targetInstanceLabel = labelMap.get(tid)
except Exception:
pass
@@ -1465,6 +1480,85 @@ async def _executeWorkflow(
return result
+@router.post("/execute")
+@limiter.limit("30/minute")
+async def _executeWorkflowFromBody(
+ request: Request,
+ body: dict = Body(..., description="{ workflowId?, graph?, targetInstanceId?, payload?, runEnvelope? }"),
+ context: RequestContext = Depends(getRequestContext),
+) -> dict:
+ """Execute a workflow — workflowId from body or ad-hoc graph execution."""
+ 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
+ workflowId = body.get("workflowId") or ""
+ targetInstanceId = body.get("targetInstanceId") or ""
+
+ wf = None
+ if workflowId:
+ 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") if wf else None) or str(context.mandateId or "")
+ instanceId = (wf.get("featureInstanceId") if wf else None) or targetInstanceId or str(context.featureInstanceId or "")
+ targetFeatureInstanceId = (wf.get("targetFeatureInstanceId") if wf else None) or targetInstanceId or ""
+
+ services = _getWorkflowAutomationServices(
+ context.user,
+ mandateId=mandateId,
+ featureInstanceId=instanceId,
+ )
+ discoverMethods(services)
+
+ graph = body.get("graph") or body.get("payload") or {}
+ if wf and not (graph.get("nodes") or []):
+ graph = wf.get("graph") or {}
+
+ logger.info(
+ "workflowAutomation /execute: workflowId=%s nodes=%d userId=%s",
+ workflowId, len(graph.get("nodes") or []), userId,
+ )
+
+ workflowForEnvelope = wf
+ runEnv = _buildExecuteRunEnvelope(
+ body,
+ workflowForEnvelope,
+ userId,
+ getattr(context.user, "language", None) if context.user else None,
+ )
+ wfLabel = (wf.get("label") if wf else None) or ""
+
+ iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId)
+ result = await executeGraph(
+ graph=graph,
+ services=services,
+ workflowId=workflowId or None,
+ 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"),
+ )
+ _startEmailPollerIfNeeded(result)
+ return result
+
+
# ---------------------------------------------------------------------------
# Version management
# ---------------------------------------------------------------------------
diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
index 18ab2a68..44e42583 100644
--- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py
+++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
@@ -1003,6 +1003,9 @@ class ChatService:
"""Get workflow by ID by delegating to the chat interface"""
try:
logger.debug(f"getWorkflow called with workflowId: {workflowId}")
+ if workflowId.startswith("transient-"):
+ logger.debug(f"getWorkflow: skipping DB lookup for transient workflow {workflowId}")
+ return None
result = self.interfaceDbChat.getWorkflow(workflowId)
if result:
logger.debug(f"getWorkflow returned workflow with ID: {result.id}")
diff --git a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
index 00f07bfb..c6bc0622 100644
--- a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
+++ b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py
@@ -21,7 +21,7 @@ model (see wiki/b-reference/platform/unified-data-bar.md):
- FdsRecordNode (+children)-- feature-owned FeatureDataSource records
- FdsFieldNode -- virtual per-column nodes under fdsTable
-The classes use `_inheritFlags.py` as a helper module for the actual
+The classes use `modules/serviceCenter/core/flagResolution.py` as a helper module for the actual
walk/aggregate/cascade arithmetic, so the inheritance semantics live in
one place. The classes themselves only express "what does this node type
DO" -- ownership, RBAC, persistence routing, child enumeration.
diff --git a/modules/workflowAutomation/engine/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py
index e188adab..807b6743 100644
--- a/modules/workflowAutomation/engine/executionEngine.py
+++ b/modules/workflowAutomation/engine/executionEngine.py
@@ -770,7 +770,7 @@ async def executeGraph(
waFileLogger: Optional[RunFileLogger] = None
nodeOutputs: Dict[str, Any] = dict(initialNodeOutputs or {})
- if not runId and automation2_interface and workflowId and not is_resume:
+ if not runId and automation2_interface and not is_resume:
run_context = {
"connectionMap": connectionMap,
"inputSources": inputSources,
diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
index 12dffc31..aa472f15 100644
--- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
+++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
@@ -599,9 +599,9 @@ class ActionNodeExecutor:
logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e)
return _normalizeError(e, outputSchema)
finally:
- if chatService:
+ if self.services.chat:
try:
- chatService.progressLogFinish(nodeOperationId, actionSuccess)
+ self.services.chat.progressLogFinish(nodeOperationId, actionSuccess)
except Exception:
pass
@@ -630,11 +630,11 @@ class ActionNodeExecutor:
rawBytes = coerceDocumentDataToBytes(rawData)
if isinstance(dumped, dict) and rawBytes:
try:
- _mgmt = self.services.interfaceDbComponent
+ _chatSvc = self.services.chat
_docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin"
_mimeType = dumped.get("mimeType") or "application/octet-stream"
- _fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes, folderId=persist_folder_id)
- _mgmt.createFileData(_fileItem.id, rawBytes)
+ _fileItem = _chatSvc.createFile(_docName, _mimeType, rawBytes, folderId=persist_folder_id)
+ _chatSvc.createFileData(_fileItem.id, rawBytes)
dumped["fileId"] = _fileItem.id
dumped["id"] = _fileItem.id
dumped["fileName"] = _fileItem.fileName
@@ -656,7 +656,7 @@ class ActionNodeExecutor:
"documents": docsList,
"count": len(docsList),
}
- _attachConnectionProvenance(list_out, resolvedParams, outputSchema, chatService, self.services)
+ _attachConnectionProvenance(list_out, resolvedParams, outputSchema, self.services.chat, self.services)
return normalizeToSchema(list_out, outputSchema)
extractedContext = ""
@@ -751,7 +751,7 @@ class ActionNodeExecutor:
"mode": data_dict.get("mode", resolvedParams.get("mode", "summarize")),
"count": int(data_dict.get("count", 0)),
}
- _attachConnectionProvenance(cr_out, resolvedParams, outputSchema, chatService, self.services)
+ _attachConnectionProvenance(cr_out, resolvedParams, outputSchema, self.services.chat, self.services)
return normalizeToSchema(cr_out, outputSchema)
if nodeDef.get("popDocumentsFromOutput"):
@@ -760,7 +760,7 @@ class ActionNodeExecutor:
if outputSchema in ("AiResult", "ActionResult") and result.success:
_attach_unified_presentation_data(out, node_def=nodeDef)
- _attachConnectionProvenance(out, resolvedParams, outputSchema, chatService, self.services)
+ _attachConnectionProvenance(out, resolvedParams, outputSchema, self.services.chat, self.services)
# When the node declares ``surfaceDataAsTopLevel`` (typical for
# dynamic-schema context nodes whose output keys are graph-defined),
diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py
index 5172ced2..19677d2b 100644
--- a/modules/workflows/methods/methodContext/actions/extractContent.py
+++ b/modules/workflows/methods/methodContext/actions/extractContent.py
@@ -1194,8 +1194,8 @@ def _persist_extracted_image_parts(
)
return content_extracted_serial, artifacts
- if services and hasattr(services, "interfaceDbComponent"):
- mgmt = services.interfaceDbComponent
+ if services and hasattr(services, "chat"):
+ mgmt = services.chat
else:
from modules.interfaces.interfaceDbManagement import getInterface as _get_mgmt
from modules.security.rootAccess import getRootUser
@@ -1206,7 +1206,7 @@ def _persist_extracted_image_parts(
return content_extracted_serial, artifacts
if not mgmt:
- logger.warning("extractContent image persist: no interfaceDbComponent available")
+ logger.warning("extractContent image persist: no chat service available")
return content_extracted_serial, artifacts
stem = re.sub(r"[^\w\-]+", "_", name_stem).strip("_") or "extract"
@@ -1310,11 +1310,11 @@ _IMAGE_MAX_DIMENSION = 1200
def _get_mgmt_for_presentation_render(services: Any) -> Optional[Any]:
- mgmt = getattr(services, "interfaceDbComponent", None) if services else None
- if mgmt:
- return mgmt
if not services:
return None
+ chat = getattr(services, "chat", None)
+ if chat:
+ return chat
try:
import modules.interfaces.interfaceDbManagement as iface
@@ -1385,7 +1385,7 @@ def _load_image_bytes_by_file_id(services: Any, file_id: str) -> Optional[bytes]
if not mgmt or not hasattr(mgmt, "getFileData"):
raise ValueError(
"no management interface available to load persisted image bytes — "
- "services.interfaceDbComponent / mandate / instance must be set"
+ "services.chat / mandate / instance must be set"
)
return mgmt.getFileData(str(file_id))
diff --git a/tests/unit/services/test_inheritFlags.py b/tests/unit/services/test_inheritFlags.py
index be3b41cf..07b29bc0 100644
--- a/tests/unit/services/test_inheritFlags.py
+++ b/tests/unit/services/test_inheritFlags.py
@@ -17,7 +17,7 @@ import unittest
from typing import List
from unittest.mock import MagicMock
-from modules.serviceCenter.services.serviceKnowledge import _inheritFlags
+from modules.serviceCenter.core import flagResolution as _inheritFlags
def _ds(idVal: str, path: str, **flags) -> dict:
From dce41a01acaeba4e345cc2481184b539639fff8c Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 9 Jun 2026 23:40:43 +0200
Subject: [PATCH 15/16] fix: unit tests and pdf bullet rendering
Co-authored-by: Cursor
---
.../renderers/rendererPdf.py | 38 ++++++++++++++-----
.../workflow/test_extract_content_handover.py | 4 +-
tests/unit/workflow/test_node_combinations.py | 2 +-
3 files changed, 32 insertions(+), 12 deletions(-)
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
index 0543a7f3..d1fe3b20 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
@@ -828,7 +828,15 @@ class RendererPdf(BaseRenderer):
return []
def _renderJsonBulletList(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]:
- """Render a JSON bullet list to PDF elements."""
+ """Render a JSON bullet list to PDF elements.
+
+ Uses ReportLab's built-in ``bulletText`` parameter for proper hanging
+ indent: the bullet/number is drawn at ``bulletIndent`` while all text
+ lines (including continuation) start at ``leftIndent``. This avoids
+ the previous approach of prepending the bullet character to the text
+ which caused misaligned wrap lines when the character width did not
+ match the indent value.
+ """
try:
content = list_data.get("content", {})
if not isinstance(content, dict):
@@ -836,27 +844,39 @@ class RendererPdf(BaseRenderer):
items = content.get("items", [])
bulletStyleDef = styles.get("bullet_list", {})
indent = bulletStyleDef.get("indent", 18)
+ fs = bulletStyleDef.get("font_size", 11)
+
+ us = getattr(self, '_unifiedStyle', None)
+ primaryFont = us["fonts"]["primary"] if us else "Calibri"
+ fontName = _resolveFontFamily(primaryFont, False)
+
+ isNumbered = content.get("list_type") == "numbered"
+ bulletChar = bulletStyleDef.get("bullet_char", "\u2022")
+
bulletStyle = ParagraphStyle(
"BulletItem",
- fontSize=bulletStyleDef.get("font_size", 11),
+ fontName=fontName,
+ fontSize=fs,
textColor=self._hexToColor(bulletStyleDef.get("color", styles.get("colors", {}).get("primary", "#24292e"))),
leftIndent=indent,
- firstLineIndent=-indent,
+ bulletIndent=0,
+ bulletFontName=fontName,
+ bulletFontSize=fs,
spaceAfter=2,
- leading=bulletStyleDef.get("font_size", 11) * 1.25,
+ leading=fs * 1.25,
)
- bulletChar = bulletStyleDef.get("bullet_char", "\u2022")
elements = []
- for item in items:
+ for idx, item in enumerate(items):
+ marker = f"{idx + 1}." if isNumbered else bulletChar
runs = self._inlineRunsForListItem(item)
if isinstance(item, list):
xml = self._renderInlineRunsToPdfXml(runs)
- elements.append(Paragraph(f"{bulletChar} {_wrapEmojiSpansInXml(xml)}", bulletStyle))
+ elements.append(Paragraph(_wrapEmojiSpansInXml(xml), bulletStyle, bulletText=marker))
elif isinstance(item, str):
- elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item)}", bulletStyle))
+ elements.append(Paragraph(self._markdownInlineToReportlabXml(item), bulletStyle, bulletText=marker))
elif isinstance(item, dict) and "text" in item:
- elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item['text'])}", bulletStyle))
+ elements.append(Paragraph(self._markdownInlineToReportlabXml(item['text']), bulletStyle, bulletText=marker))
if elements:
elements.append(Spacer(1, bulletStyleDef.get("space_after", 3)))
diff --git a/tests/unit/workflow/test_extract_content_handover.py b/tests/unit/workflow/test_extract_content_handover.py
index 401b7a16..f18a3fc6 100644
--- a/tests/unit/workflow/test_extract_content_handover.py
+++ b/tests/unit/workflow/test_extract_content_handover.py
@@ -554,7 +554,7 @@ def test_presentation_envelopes_preserves_data_slot_order_text_image_text():
)
class _Svc:
- interfaceDbComponent = _Mgmt()
+ chat = _Mgmt()
pres = {
"kind": PRESENTATION_KIND,
@@ -666,7 +666,7 @@ def test_presentation_envelopes_to_document_json_image_slot():
return b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
class _Svc:
- interfaceDbComponent = _Mgmt()
+ chat = _Mgmt()
out = presentation_envelopes_to_document_json(
pres,
diff --git a/tests/unit/workflow/test_node_combinations.py b/tests/unit/workflow/test_node_combinations.py
index 3c807f19..c26d4f07 100644
--- a/tests/unit/workflow/test_node_combinations.py
+++ b/tests/unit/workflow/test_node_combinations.py
@@ -596,7 +596,7 @@ def test_extract_image_slot_carries_file_id_and_mime():
class _Services:
def __init__(self):
- self.interfaceDbComponent = _MgmtStub()
+ self.chat = _MgmtStub()
envelope = {
"schemaVersion": PRESENTATION_SCHEMA_VERSION,
From 30db7a310cab0b3649375e2520c4cbe22e0efdad Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 9 Jun 2026 23:52:50 +0200
Subject: [PATCH 16/16] fix: resolve all deprecation warnings and remove dead
test scripts
Co-authored-by: Cursor
---
.../realEstate/datamodelFeatureRealEstate.py | 438 +++---------------
.../features/redmine/routeFeatureRedmine.py | 4 +-
tests/functional/test_kpi_full.py | 97 ----
tests/functional/test_kpi_incomplete.py | 134 ------
tests/functional/test_kpi_path.py | 68 ---
.../test_featureCatalogLabels_i18n.py | 6 +-
6 files changed, 76 insertions(+), 671 deletions(-)
delete mode 100644 tests/functional/test_kpi_full.py
delete mode 100644 tests/functional/test_kpi_incomplete.py
delete mode 100644 tests/functional/test_kpi_path.py
diff --git a/modules/features/realEstate/datamodelFeatureRealEstate.py b/modules/features/realEstate/datamodelFeatureRealEstate.py
index 346e998b..cf2c6c67 100644
--- a/modules/features/realEstate/datamodelFeatureRealEstate.py
+++ b/modules/features/realEstate/datamodelFeatureRealEstate.py
@@ -57,37 +57,17 @@ class GeoTag(str, Enum):
class GeoPunkt(BaseModel):
"""Represents a 3D point with reference."""
koordinatensystem: str = Field(
- description="Coordinate system (e.g. 'LV95', 'EPSG:2056')",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="Coordinate system (e.g. 'LV95', 'EPSG:2056')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
x: float = Field(
- description="East value (E) [m], typically 2'480'000 - 2'840'000",
- frontend_type="number",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="East value (E) [m], typically 2'480'000 - 2'840'000", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": True})
y: float = Field(
- description="North value (N) [m], typically 1'070'000 - 1'300'000",
- frontend_type="number",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="North value (N) [m], typically 1'070'000 - 1'300'000", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": True})
z: Optional[float] = Field(
None,
- description="Height above sea level [m]",
- frontend_type="number",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Height above sea level [m]", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
referenz: Optional[GeoTag] = Field(
None,
- description="Point categorization",
- frontend_type="select",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Point categorization", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
class GeoPolylinie(BaseModel):
@@ -97,18 +77,10 @@ class GeoPolylinie(BaseModel):
description="Primary key",
)
closed: bool = Field(
- description="Is the GeoPolylinie closed (polygon)?",
- frontend_type="boolean",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="Is the GeoPolylinie closed (polygon)?", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": False, "frontend_required": True})
punkte: List[GeoPunkt] = Field(
default_factory=list,
- description="List of GeoPunkte forming the GeoPolylinie",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="List of GeoPunkte forming the GeoPolylinie", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": True})
@i18nModel("Dokument")
@@ -117,73 +89,33 @@ class Dokument(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- label="ID",
- )
+ json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(
description="ID of the mandate this document belongs to",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- label="Mandats-ID",
- )
+ json_schema_extra={"label": "Mandats-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: str = Field(
description="ID of the feature instance this document belongs to",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- label="Feature-Instanz-ID",
- )
+ json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
label: str = Field(
description="Document label",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=True,
- label="Bezeichnung",
- )
+ json_schema_extra={"label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
versionsbezeichnung: Optional[str] = Field(
None,
- description="Version number or designation (e.g. 'v1.0', 'Rev. A')",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Version number or designation (e.g. 'v1.0', 'Rev. A')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
dokumentTyp: Optional[DokumentTyp] = Field(
None,
- description="Document type",
- frontend_type="select",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Document type", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
dokumentReferenz: str = Field(
- description="File path or URL",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="File path or URL", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
quelle: Optional[str] = Field(
None,
- description="Source of the document",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Source of the document", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
mimeType: Optional[str] = Field(
None,
- description="MIME type of the document (e.g. 'application/pdf', 'image/png')",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="MIME type of the document (e.g. 'application/pdf', 'image/png')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
kategorienTags: List[str] = Field(
default_factory=list,
- description="Document categorization tags",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Document categorization tags", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
class Kontext(PowerOnModel):
@@ -193,78 +125,38 @@ class Kontext(PowerOnModel):
description="Primary key",
)
thema: str = Field(
- description="Theme designation",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="Theme designation", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
inhalt: str = Field(
- description="Detailed information (text)",
- frontend_type="textarea",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="Detailed information (text)", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True})
class Land(PowerOnModel):
"""National level administrative entity."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
- description="Primary key",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- )
+ description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(
- description="ID of the mandate",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- )
+ description="ID of the mandate", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: str = Field(
- description="ID of the feature instance",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- )
+ description="ID of the feature instance", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
label: str = Field(
- description="Country name (e.g. 'Schweiz')",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="Country name (e.g. 'Schweiz')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
abk: Optional[str] = Field(
None,
- description="Abbreviation (e.g. 'CH')",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Abbreviation (e.g. 'CH')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
dokumente: List[Dokument] = Field(
default_factory=list,
- description="National laws/documents",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="National laws/documents", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
kontextInformationen: List[Kontext] = Field(
default_factory=list,
- description="National context information",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="National context information", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
class Kanton(PowerOnModel):
"""Cantonal level administrative entity."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
- description="Primary key",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- )
+ description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(
description="ID of the mandate",
json_schema_extra={
@@ -282,11 +174,7 @@ class Kanton(PowerOnModel):
},
)
label: str = Field(
- description="Canton name (e.g. 'Zürich')",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="Canton name (e.g. 'Zürich')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
id_land: Optional[str] = Field(
None,
description="Land ID (Foreign Key) - eindeutiger Link zum Land, in welchem Land der Kanton liegt",
@@ -299,54 +187,26 @@ class Kanton(PowerOnModel):
)
abk: Optional[str] = Field(
None,
- description="Abbreviation (e.g. 'ZH')",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Abbreviation (e.g. 'ZH')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
dokumente: List[Dokument] = Field(
default_factory=list,
- description="Cantonal documents",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Cantonal documents", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
kontextInformationen: List[Kontext] = Field(
default_factory=list,
- description="Canton-specific context information",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Canton-specific context information", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
class Gemeinde(PowerOnModel):
"""Municipal level administrative entity."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
- description="Primary key",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- )
+ description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(
- description="ID of the mandate",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- )
+ description="ID of the mandate", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: str = Field(
- description="ID of the feature instance",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- )
+ description="ID of the feature instance", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
label: str = Field(
- description="Municipality name (e.g. 'Zürich')",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=True,
- )
+ description="Municipality name (e.g. 'Zürich')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
id_kanton: Optional[str] = Field(
None,
description="Kanton ID (Foreign Key) - eindeutiger Link zum Kanton, in welchem Kanton die Gemeinde liegt",
@@ -359,25 +219,13 @@ class Gemeinde(PowerOnModel):
)
plz: Optional[str] = Field(
None,
- description="Postal code (for municipalities with multiple PLZ, this can be a main PLZ). Bei Gemeinden mit mehreren Postleitzahlen wird die konkrete PLZ der Parzelle im Attribut `plz` der Parzelle erfasst.",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Postal code (for municipalities with multiple PLZ, this can be a main PLZ). Bei Gemeinden mit mehreren Postleitzahlen wird die konkrete PLZ der Parzelle im Attribut `plz` der Parzelle erfasst.", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
dokumente: List[Dokument] = Field(
default_factory=list,
- description="Municipal documents",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Municipal documents", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
kontextInformationen: List[Kontext] = Field(
default_factory=list,
- description="Municipality-specific context information",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Municipality-specific context information", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
# ===== Main Models (use ForwardRef for circular references) =====
@@ -392,11 +240,7 @@ class Parzelle(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- label="ID",
- )
+ json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(
description="ID of the mandate",
json_schema_extra={
@@ -421,55 +265,27 @@ class Parzelle(PowerOnModel):
# Grunddaten
label: str = Field(
description="Plot designation",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=True,
- label="Bezeichnung",
- )
+ json_schema_extra={"label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
parzellenAliasTags: List[str] = Field(
default_factory=list,
- description="Additional plot names or field names",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Additional plot names or field names", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
eigentuemerschaft: Optional[str] = Field(
None,
- description="Owner of the plot",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Owner of the plot", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
strasseNr: Optional[str] = Field(
None,
- description="Street and house number",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Street and house number", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
plz: Optional[str] = Field(
None,
- description="Postal code of the plot",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Postal code of the plot", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
# Geografischer Kontext
perimeter: Optional[GeoPolylinie] = Field(
None,
- description="Plot boundary as closed GeoPolylinie",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Plot boundary as closed GeoPolylinie", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
baulinie: Optional[GeoPolylinie] = Field(
None,
- description="Building line of the plot",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Building line of the plot", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
kontextGemeinde: Optional[str] = Field(
None,
@@ -485,145 +301,69 @@ class Parzelle(PowerOnModel):
# Bebauungsparameter
bauzone: Optional[str] = Field(
None,
- description="Building zone designation (e.g. W3, WG2, etc.)",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Building zone designation (e.g. W3, WG2, etc.)", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
az: Optional[float] = Field(
None,
- description="Ausnützungsziffer",
- frontend_type="number",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Ausnützungsziffer", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
bz: Optional[float] = Field(
None,
- description="Bebauungsziffer",
- frontend_type="number",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Bebauungsziffer", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
vollgeschossZahl: Optional[int] = Field(
None,
- description="Number of allowed full floors",
- frontend_type="number",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Number of allowed full floors", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
anrechenbarDachgeschoss: Optional[float] = Field(
None,
- description="Accountable portion of attic (0.0 - 1.0)",
- frontend_type="number",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Accountable portion of attic (0.0 - 1.0)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
anrechenbarUntergeschoss: Optional[float] = Field(
None,
- description="Accountable portion of basement (0.0 - 1.0)",
- frontend_type="number",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Accountable portion of basement (0.0 - 1.0)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
gebaeudehoeheMax: Optional[float] = Field(
None,
- description="Maximum building height in meters",
- frontend_type="number",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Maximum building height in meters", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
# Abstandsregelungen
regelnGrenzabstand: List[str] = Field(
default_factory=list,
- description="Regulations for boundary distance",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Regulations for boundary distance", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
regelnMehrlaengenzuschlag: List[str] = Field(
default_factory=list,
- description="Regulations for additional length surcharge",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Regulations for additional length surcharge", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
regelnMehrhoehenzuschlag: List[str] = Field(
default_factory=list,
- description="Regulations for additional height surcharge",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Regulations for additional height surcharge", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
# Eigenschaften (Ja/Nein)
parzelleBebaut: Optional[JaNein] = Field(
None,
- description="Is the plot built?",
- frontend_type="select",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Is the plot built?", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
parzelleErschlossen: Optional[JaNein] = Field(
None,
- description="Is the plot developed?",
- frontend_type="select",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Is the plot developed?", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
parzelleHanglage: Optional[JaNein] = Field(
None,
- description="Is the plot on a slope?",
- frontend_type="select",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Is the plot on a slope?", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
# Schutzzonen
laermschutzzone: Optional[str] = Field(
None,
- description="Noise protection zone (e.g. 'II')",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Noise protection zone (e.g. 'II')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
hochwasserschutzzone: Optional[str] = Field(
None,
- description="Flood protection zone (e.g. 'tief')",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Flood protection zone (e.g. 'tief')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
grundwasserschutzzone: Optional[str] = Field(
None,
- description="Groundwater protection zone",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Groundwater protection zone", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
# Beziehungen (stored as JSONB in database)
parzellenNachbarschaft: List[Dict[str, Any]] = Field(
default_factory=list,
- description="Neighboring plots (stored as list of Parzelle IDs or full objects)",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Neighboring plots (stored as list of Parzelle IDs or full objects)", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
dokumente: List[Dokument] = Field(
default_factory=list,
- description="Plot-specific documents",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Plot-specific documents", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
kontextInformationen: List[Kontext] = Field(
default_factory=list,
- description="Plot-specific context information",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Plot-specific context information", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
@i18nModel("Projekt")
@@ -632,11 +372,7 @@ class Projekt(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False,
- label="ID",
- )
+ json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(
description="ID of the mandate",
json_schema_extra={
@@ -659,54 +395,26 @@ class Projekt(PowerOnModel):
)
label: str = Field(
description="Project designation",
- frontend_type="text",
- frontend_readonly=False,
- frontend_required=True,
- label="Bezeichnung",
- )
+ json_schema_extra={"label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
statusProzess: Optional[StatusProzess] = Field(
None,
description="Project status",
- frontend_type="select",
- frontend_readonly=False,
- frontend_required=False,
- label="Prozessstatus",
- )
+ json_schema_extra={"label": "Prozessstatus", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
perimeter: Optional[GeoPolylinie] = Field(
None,
- description="Envelope of all plots in the project",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Envelope of all plots in the project", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
baulinie: Optional[GeoPolylinie] = Field(
None,
- description="Building line of the project",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Building line of the project", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
parzellen: List[Parzelle] = Field(
default_factory=list,
- description="All plots of the project",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="All plots of the project", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
dokumente: List[Dokument] = Field(
default_factory=list,
- description="Project-specific documents",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Project-specific documents", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
kontextInformationen: List[Kontext] = Field(
default_factory=list,
- description="Project-specific context information",
- frontend_type="json",
- frontend_readonly=False,
- frontend_required=False,
- )
+ description="Project-specific context information", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
# Resolve forward references
diff --git a/modules/features/redmine/routeFeatureRedmine.py b/modules/features/redmine/routeFeatureRedmine.py
index 86ac8d30..d3f5b771 100644
--- a/modules/features/redmine/routeFeatureRedmine.py
+++ b/modules/features/redmine/routeFeatureRedmine.py
@@ -457,10 +457,10 @@ async def getStats(
instanceId: str,
dateFrom: Optional[str] = Query(default=None, description="ISO date YYYY-MM-DD"),
dateTo: Optional[str] = Query(default=None, description="ISO date YYYY-MM-DD"),
- bucket: str = Query(default="week", regex="^(day|week|month)$"),
+ bucket: str = Query(default="week", pattern="^(day|week|month)$"),
trackerIds: Optional[List[int]] = Query(default=None),
categoryIds: Optional[List[int]] = Query(default=None, description="Filter by Redmine issue categories"),
- statusFilter: str = Query(default="*", regex="^(\\*|open|closed)$", description="Restrict to open/closed/all tickets"),
+ statusFilter: str = Query(default="*", pattern="^(\\*|open|closed)$", description="Restrict to open/closed/all tickets"),
context: RequestContext = Depends(getRequestContext),
) -> RedmineStatsDto:
mandateId = _validateInstanceAccess(instanceId, context)
diff --git a/tests/functional/test_kpi_full.py b/tests/functional/test_kpi_full.py
deleted file mode 100644
index aa8d8540..00000000
--- a/tests/functional/test_kpi_full.py
+++ /dev/null
@@ -1,97 +0,0 @@
-# Copyright (c) 2026 PowerOn AG
-# All rights reserved.
-"""Test full KPI extraction and validation flow"""
-import json
-import sys
-import os
-import pytest
-
-# Add gateway directory to path
-_gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
-if _gateway_path not in sys.path:
- sys.path.insert(0, _gateway_path)
-
-from modules.serviceCenter.services.serviceAi.subJsonResponseHandling import JsonResponseHandler
-from modules.datamodels.datamodelAi import JsonAccumulationState
-
-# Load actual JSON response
-json_file = os.path.join(
- os.path.dirname(__file__),
- "..", "..", "..", "local", "debug", "prompts",
- "20251130-211706-078-document_generation_response.txt"
-)
-
-if not os.path.exists(json_file):
- pytest.skip(f"Test data file not found: {json_file}", allow_module_level=True)
-
-with open(json_file, 'r', encoding='utf-8') as f:
- content = f.read()
-
-# Extract JSON
-from modules.shared.jsonUtils import extractJsonString
-extracted = extractJsonString(content)
-parsedJson = json.loads(extracted)
-
-# KPI definition from the response
-kpiDefinitions = [{
- "id": "prime_numbers_count",
- "description": "Number of prime numbers generated and organized in the table",
- "jsonPath": "documents[0].sections[0].elements[0].rows",
- "targetValue": 4000
-}]
-
-print("="*60)
-print("KPI EXTRACTION AND VALIDATION TEST")
-print("="*60)
-
-# Step 1: Initialize accumulation state with KPIs
-accumulationState = JsonAccumulationState(
- accumulatedJsonString="",
- isAccumulationMode=True,
- lastParsedResult=None,
- allSections=[],
- kpis=[{**kpi, "currentValue": 0} for kpi in kpiDefinitions]
-)
-
-print(f"\nStep 1: Initialized KPIs")
-for kpi in accumulationState.kpis:
- print(f" KPI {kpi['id']}: currentValue={kpi.get('currentValue', 'N/A')}, targetValue={kpi.get('targetValue', 'N/A')}")
-
-# Step 2: Extract KPI values from parsed JSON
-print(f"\nStep 2: Extracting KPI values from JSON...")
-updatedKpis = JsonResponseHandler.extractKpiValuesFromJson(
- parsedJson,
- accumulationState.kpis
-)
-
-print(f" Extracted {len(updatedKpis)} KPIs")
-for kpi in updatedKpis:
- print(f" KPI {kpi['id']}: currentValue={kpi.get('currentValue', 'N/A')}, targetValue={kpi.get('targetValue', 'N/A')}")
-
-# Step 3: Validate progression
-print(f"\nStep 3: Validating KPI progression...")
-shouldProceed, reason = JsonResponseHandler.validateKpiProgression(
- accumulationState,
- updatedKpis
-)
-
-print(f" Result: shouldProceed={shouldProceed}, reason={reason}")
-
-# Step 4: Check what's in accumulationState.kpis vs updatedKpis
-print(f"\nStep 4: Comparing state...")
-print(f" accumulationState.kpis[0].currentValue = {accumulationState.kpis[0].get('currentValue', 'N/A')}")
-print(f" updatedKpis[0].currentValue = {updatedKpis[0].get('currentValue', 'N/A')}")
-
-# Step 5: Check if we need to update accumulationState.kpis
-print(f"\nStep 5: Updating accumulationState.kpis...")
-accumulationState.kpis = updatedKpis
-print(f" Updated accumulationState.kpis[0].currentValue = {accumulationState.kpis[0].get('currentValue', 'N/A')}")
-
-# Step 6: Validate again (should show progress)
-print(f"\nStep 6: Validating again after update...")
-shouldProceed2, reason2 = JsonResponseHandler.validateKpiProgression(
- accumulationState,
- updatedKpis
-)
-print(f" Result: shouldProceed={shouldProceed2}, reason={reason2}")
-
diff --git a/tests/functional/test_kpi_incomplete.py b/tests/functional/test_kpi_incomplete.py
deleted file mode 100644
index b9125d9f..00000000
--- a/tests/functional/test_kpi_incomplete.py
+++ /dev/null
@@ -1,134 +0,0 @@
-# Copyright (c) 2026 PowerOn AG
-# All rights reserved.
-"""Test KPI extraction with incomplete JSON"""
-import json
-import sys
-import os
-import pytest
-
-# Add gateway directory to path
-_gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
-if _gateway_path not in sys.path:
- sys.path.insert(0, _gateway_path)
-
-from modules.serviceCenter.services.serviceAi.subJsonResponseHandling import JsonResponseHandler
-from modules.datamodels.datamodelAi import JsonAccumulationState
-from modules.shared.jsonUtils import extractJsonString, repairBrokenJson
-
-# Load actual incomplete JSON response
-json_file = os.path.join(
- os.path.dirname(__file__),
- "..", "..", "..", "local", "debug", "prompts",
- "20251130-211706-078-document_generation_response.txt"
-)
-
-if not os.path.exists(json_file):
- pytest.skip(f"Test data file not found: {json_file}", allow_module_level=True)
-
-with open(json_file, 'r', encoding='utf-8') as f:
- content = f.read()
-
-print("="*60)
-print("KPI EXTRACTION WITH INCOMPLETE JSON TEST")
-print("="*60)
-
-# Step 1: Try to extract and parse JSON
-print(f"\nStep 1: Extracting JSON string...")
-extracted = extractJsonString(content)
-print(f" Extracted length: {len(extracted)} chars")
-
-# Step 2: Try to parse
-print(f"\nStep 2: Attempting to parse...")
-parsedJson = None
-try:
- parsedJson = json.loads(extracted)
- print(f" ✅ JSON parsed successfully")
-except json.JSONDecodeError as e:
- print(f" ❌ JSON parsing failed: {e}")
- print(f" Attempting repair...")
- try:
- parsedJson = repairBrokenJson(extracted)
- if parsedJson:
- print(f" ✅ JSON repaired successfully")
- else:
- print(f" ❌ JSON repair failed")
- except Exception as e2:
- print(f" ❌ Repair error: {e2}")
-
-if not parsedJson:
- pytest.skip("Cannot proceed - JSON cannot be parsed or repaired", allow_module_level=True)
-
-# Step 3: Check if path exists
-print(f"\nStep 3: Checking if KPI path exists...")
-path = "documents[0].sections[0].elements[0].rows"
-try:
- value = JsonResponseHandler._extractValueByPath(parsedJson, path)
- print(f" ✅ Path exists: {type(value)}")
- if isinstance(value, list):
- print(f" ✅ Value is list with {len(value)} items")
- if len(value) > 0:
- print(f" ✅ First item: {value[0]}")
- else:
- print(f" ⚠️ Value is not a list: {value}")
-except Exception as e:
- print(f" ❌ Path extraction failed: {e}")
- import traceback
- traceback.print_exc()
- pytest.skip(f"Path extraction failed: {e}", allow_module_level=True)
-
-# Step 4: Test KPI extraction
-print(f"\nStep 4: Testing KPI extraction...")
-kpiDefinitions = [{
- "id": "prime_numbers_count",
- "description": "Number of prime numbers generated and organized in the table",
- "jsonPath": "documents[0].sections[0].elements[0].rows",
- "targetValue": 4000
-}]
-
-accumulationState = JsonAccumulationState(
- accumulatedJsonString="",
- isAccumulationMode=True,
- lastParsedResult=parsedJson,
- allSections=[],
- kpis=[{**kpi, "currentValue": 0} for kpi in kpiDefinitions]
-)
-
-print(f" Initial KPI currentValue: {accumulationState.kpis[0].get('currentValue', 'N/A')}")
-
-updatedKpis = JsonResponseHandler.extractKpiValuesFromJson(
- parsedJson,
- accumulationState.kpis
-)
-
-print(f" Updated KPI currentValue: {updatedKpis[0].get('currentValue', 'N/A')}")
-
-# Step 5: Test validation
-print(f"\nStep 5: Testing KPI validation...")
-shouldProceed, reason = JsonResponseHandler.validateKpiProgression(
- accumulationState,
- updatedKpis
-)
-
-print(f" Result: shouldProceed={shouldProceed}, reason={reason}")
-
-if not shouldProceed:
- print(f"\n❌ VALIDATION FAILED - This is the problem!")
- print(f" Let's debug why...")
-
- # Check what's being compared
- lastValues = {kpi.get("id"): kpi.get("currentValue", 0) for kpi in accumulationState.kpis}
- print(f" Last values from accumulationState: {lastValues}")
-
- for updatedKpi in updatedKpis:
- kpiId = updatedKpi.get("id")
- currentValue = updatedKpi.get("currentValue", 0)
- print(f" Updated KPI {kpiId}: currentValue={currentValue}")
-
- if kpiId in lastValues:
- lastValue = lastValues[kpiId]
- print(f" Comparing: {lastValue} vs {currentValue}")
- if currentValue > lastValue:
- print(f" ✅ Should detect progress!")
- else:
- print(f" ❌ No progress detected (currentValue <= lastValue)")
-
diff --git a/tests/functional/test_kpi_path.py b/tests/functional/test_kpi_path.py
deleted file mode 100644
index c0a32862..00000000
--- a/tests/functional/test_kpi_path.py
+++ /dev/null
@@ -1,68 +0,0 @@
-# Copyright (c) 2026 PowerOn AG
-# All rights reserved.
-"""Test KPI path extraction"""
-import json
-import sys
-import os
-
-# Add gateway directory to path
-_gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
-if _gateway_path not in sys.path:
- sys.path.insert(0, _gateway_path)
-
-from modules.serviceCenter.services.serviceAi.subJsonResponseHandling import JsonResponseHandler
-
-# Test JSON matching the actual response
-test_json = {
- "metadata": {
- "split_strategy": "single_document",
- "source_documents": [],
- "extraction_method": "ai_generation"
- },
- "documents": [
- {
- "id": "doc_1",
- "title": "Prime Numbers Table",
- "filename": "prime_numbers.json",
- "sections": [
- {
- "id": "section_prime_numbers_table",
- "content_type": "table",
- "elements": [
- {
- "headers": ["Column 1", "Column 2"],
- "rows": [
- [2, 3, 5, 7, 11],
- [13, 17, 19, 23, 29]
- ]
- }
- ]
- }
- ]
- }
- ]
-}
-
-# Test path from KPI definition
-path = "documents[0].sections[0].elements[0].rows"
-
-print(f"Testing path: {path}")
-print(f"JSON structure: documents[0].sections[0].elements[0].rows")
-print()
-
-try:
- value = JsonResponseHandler._extractValueByPath(test_json, path)
- print(f"✅ Extracted value: {type(value)}")
- print(f" Value: {value}")
-
- if isinstance(value, list):
- count = len(value)
- print(f" Count: {count}")
- else:
- print(f" Not a list!")
-
-except Exception as e:
- print(f"❌ Error: {e}")
- import traceback
- traceback.print_exc()
-
diff --git a/tests/validation/test_featureCatalogLabels_i18n.py b/tests/validation/test_featureCatalogLabels_i18n.py
index ffdf1c2b..f5b2b490 100644
--- a/tests/validation/test_featureCatalogLabels_i18n.py
+++ b/tests/validation/test_featureCatalogLabels_i18n.py
@@ -30,11 +30,7 @@ _FEATURES_DIR = Path(__file__).resolve().parents[2] / "modules" / "features"
_BARE_LABEL_PATTERN = re.compile(r'^\s*"label"\s*:\s*"[^"]+"', re.MULTILINE)
-# mainRealEstate.py contains "label": "AA1704" inside a multi-line f-string
-# that is used as a JSON example in an AI prompt -- not a real catalog entry.
-_ALLOWED_FILES_WITH_BARE_LABELS: set[str] = {
- "mainRealEstate.py",
-}
+_ALLOWED_FILES_WITH_BARE_LABELS: set[str] = set()
def _findFeatureMainFiles() -> list[Path]: