refactory workflowAutomation completed as system component reolacing automation2 and graphEditor
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 16s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped

This commit is contained in:
ValueOn AG 2026-06-08 10:31:17 +02:00
parent 39aba4cca8
commit 9be2d8aab5
111 changed files with 2726 additions and 4247 deletions

18
app.py
View file

@ -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)

View file

@ -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."
),

View file

@ -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",

View file

@ -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."""

View file

@ -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

View file

@ -1,2 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# GraphicalEditor feature - n8n-style flow automation with visual editor

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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 = []

View file

@ -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():

View file

@ -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``.
"""

View file

@ -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,
)

View file

@ -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}",

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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,
}

View file

@ -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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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."
),
)

View file

@ -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",

View file

@ -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,

View file

@ -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]}"

View file

@ -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}

View file

@ -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)

View file

@ -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",

View file

@ -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
"""

View file

@ -0,0 +1,5 @@
"""
workflowAutomation.editor Graph/Flow authoring backend.
Node registry, port types, adapters, condition operators, entry points.
"""

View file

@ -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.

View file

@ -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,

View file

@ -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

View file

@ -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 []

View file

@ -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,
)

View file

@ -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 = [
{

View file

@ -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",
}
},

View file

@ -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

View file

@ -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 = [
{

View file

@ -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 = [
{

View file

@ -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,
},
]

View file

@ -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 (35) 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": {

View file

@ -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 = [
{

View file

@ -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.

View file

@ -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,
)

View file

@ -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 = [
{

View file

@ -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[<code>]` 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"}},

View file

@ -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,

View file

@ -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)

View file

@ -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(?:\:([^\]]+))?\]$")

View file

@ -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}

View file

@ -0,0 +1,2 @@
# Copyright (c) 2025 Patrick Motsch
# automation2 - n8n-style graph execution engine.

View file

@ -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,

View file

@ -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",
]

View file

@ -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"),

View file

@ -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__)

View file

@ -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,

View file

@ -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,

View file

@ -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()))

View file

@ -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__)

View file

@ -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,

View file

@ -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__)

View file

@ -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:

View file

@ -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

View file

@ -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,
)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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,
)

View file

@ -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)

View file

@ -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:

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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():

View file

@ -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():

View file

@ -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:

View file

@ -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,

View file

@ -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:

View file

@ -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,

View file

@ -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,

View file

@ -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():

View file

@ -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):

View file

@ -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."
)

View file

@ -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():

View file

@ -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:

View file

@ -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():

View file

@ -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.

Some files were not shown because too many files have changed in this diff Show more