refactory workflowAutomation completed as system component reolacing automation2 and graphEditor
This commit is contained in:
parent
39aba4cca8
commit
9be2d8aab5
111 changed files with 2726 additions and 4247 deletions
18
app.py
18
app.py
|
|
@ -432,7 +432,7 @@ async def lifespan(app: FastAPI):
|
|||
try:
|
||||
main_loop = asyncio.get_running_loop()
|
||||
eventManager.set_event_loop(main_loop)
|
||||
from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback
|
||||
from modules.workflowAutomation.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback
|
||||
setSchedulerMainLoop(main_loop)
|
||||
|
||||
# Inject run-failed notification callback (Composition Root — avoids workflows→serviceCenter import)
|
||||
|
|
@ -452,10 +452,10 @@ async def lifespan(app: FastAPI):
|
|||
user=eventUser,
|
||||
mandate_id=mandateId or "",
|
||||
feature_instance_id="",
|
||||
feature_code="graphicalEditor",
|
||||
feature_code="workflowAutomation",
|
||||
)
|
||||
messagingService = getService("messaging", ctx)
|
||||
subscriptionId = "GraphicalEditorRunFailed"
|
||||
subscriptionId = "WorkflowAutomationRunFailed"
|
||||
eventParams = MessagingEventParameters(triggerData={
|
||||
"workflowId": workflowId,
|
||||
"workflowLabel": workflowLabel or workflowId,
|
||||
|
|
@ -484,7 +484,7 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
# --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) ---
|
||||
try:
|
||||
from modules.workflows.scheduler.mainScheduler import start as _startWorkflowScheduler
|
||||
from modules.workflowAutomation.scheduler.mainScheduler import start as _startWorkflowScheduler
|
||||
_startWorkflowScheduler(eventUser)
|
||||
logger.info("WorkflowAutomation scheduler started (system lifespan)")
|
||||
except Exception as e:
|
||||
|
|
@ -572,12 +572,12 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
# 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan)
|
||||
try:
|
||||
from modules.workflows.scheduler.mainScheduler import stop as _stopWorkflowScheduler
|
||||
from modules.workflowAutomation.scheduler.mainScheduler import stop as _stopWorkflowScheduler
|
||||
_stopWorkflowScheduler()
|
||||
except Exception as e:
|
||||
logger.warning(f"WorkflowAutomation scheduler stop failed: {e}")
|
||||
try:
|
||||
from modules.features.graphicalEditor.emailPoller import stop as _stopEmailPoller
|
||||
from modules.workflowAutomation.scheduler.emailPoller import stop as _stopEmailPoller
|
||||
_stopEmailPoller(eventUser)
|
||||
except Exception as e:
|
||||
logger.warning(f"Email poller stop failed: {e}")
|
||||
|
|
@ -863,12 +863,6 @@ from modules.routes.routeSystem import router as systemRouter, navigationRouter
|
|||
app.include_router(systemRouter)
|
||||
app.include_router(navigationRouter)
|
||||
|
||||
from modules.routes.routeWorkflowDashboard import router as workflowDashboardRouter
|
||||
app.include_router(workflowDashboardRouter)
|
||||
|
||||
from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter
|
||||
app.include_router(automationWorkspaceRouter)
|
||||
|
||||
from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter
|
||||
app.include_router(workflowAutomationRouter)
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# GraphicalEditor feature - n8n-style flow automation with visual editor
|
||||
|
|
@ -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
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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."
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]}"
|
||||
624
modules/shared/workflowAutomationHelpers.py
Normal file
624
modules/shared/workflowAutomationHelpers.py
Normal 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}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
8
modules/workflowAutomation/__init__.py
Normal file
8
modules/workflowAutomation/__init__.py
Normal 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
|
||||
"""
|
||||
5
modules/workflowAutomation/editor/__init__.py
Normal file
5
modules/workflowAutomation/editor/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""
|
||||
workflowAutomation.editor — Graph/Flow authoring backend.
|
||||
|
||||
Node registry, port types, adapters, condition operators, entry points.
|
||||
"""
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
@ -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",
|
||||
}
|
||||
},
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
@ -96,7 +96,7 @@ MERGE_RESULT_DATA_PICK_OPTIONS = [
|
|||
{
|
||||
"path": ["first"],
|
||||
"pickerLabel": t("Erster Zweig"),
|
||||
"detail": t("Daten vom ersten verbundenen Eingang (Modus „first“)."),
|
||||
"detail": t("Daten vom ersten verbundenen Eingang (Modus „first")."),
|
||||
"recommended": False,
|
||||
"type": "Any",
|
||||
},
|
||||
|
|
@ -243,9 +243,9 @@ FLOW_NODES = [
|
|||
"category": "flow",
|
||||
"label": t("Schleife / Für jedes"),
|
||||
"description": t(
|
||||
"Zwei Ausgänge: „Schleife“ verbindet den Rumpf (pro Element); optional führt der Rumpf "
|
||||
"Zwei Ausgänge: „Schleife" verbindet den Rumpf (pro Element); optional führt der Rumpf "
|
||||
"mit einem Rücklauf-Pfeil wieder zum **gleichen Eingang** wie der vorherige Schritt (wie in n8n). "
|
||||
"„Fertig“ führt genau einmal fort, wenn alle Iterationen beendet sind. "
|
||||
"„Fertig" führt genau einmal fort, wenn alle Iterationen beendet sind. "
|
||||
"Die zu durchlaufende Liste wählen Sie wie bisher; UDM-/Strukturdaten werden automatisch sinnvoll in Elemente aufgelöst."
|
||||
),
|
||||
"parameters": [
|
||||
|
|
@ -266,7 +266,7 @@ FLOW_NODES = [
|
|||
},
|
||||
"description": t(
|
||||
"Welche Elemente die Schleife besucht: alle, nur das erste/letzte, jedes zweite/dritte "
|
||||
"oder jedes n-te (Schritt dann unter „Schrittweite“)."
|
||||
"oder jedes n-te (Schritt dann unter „Schrittweite")."
|
||||
),
|
||||
"default": "all",
|
||||
},
|
||||
|
|
@ -276,7 +276,7 @@ FLOW_NODES = [
|
|||
"required": False,
|
||||
"frontendType": "number",
|
||||
"frontendOptions": {"min": 2, "max": 100},
|
||||
"description": t("Nur bei „jedes n-te“: Schrittweite (z. B. 5 = jedes 5. Element ab Index 0)."),
|
||||
"description": t("Nur bei „jedes n-te": Schrittweite (z. B. 5 = jedes 5. Element ab Index 0)."),
|
||||
"default": 2,
|
||||
},
|
||||
{
|
||||
|
|
@ -333,12 +333,6 @@ FLOW_NODES = [
|
|||
"default": 2,
|
||||
},
|
||||
],
|
||||
# ``inputs: 2`` is the static minimum / default topology. ``inputCount`` is a
|
||||
# frontend hint: the editor adds/removes input ports dynamically when the user
|
||||
# changes the value. ``FlowExecutor._merge`` collects whatever ports exist in
|
||||
# ``inputSources`` at runtime, so extra ports (3–5) work without further changes
|
||||
# to this definition. ``inputPorts`` below only type-declares the two minimum
|
||||
# ports; additional ports inherit the same ``_FLOW_INPUT_SCHEMAS`` accepts list.
|
||||
"inputs": 2,
|
||||
"outputs": 1,
|
||||
"inputPorts": {
|
||||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
@ -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"}},
|
||||
|
|
@ -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,
|
||||
|
|
@ -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)
|
||||
|
|
@ -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(?:\:([^\]]+))?\]$")
|
||||
|
|
@ -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}
|
||||
|
||||
2
modules/workflowAutomation/engine/__init__.py
Normal file
2
modules/workflowAutomation/engine/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# automation2 - n8n-style graph execution engine.
|
||||
|
|
@ -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,
|
||||
18
modules/workflowAutomation/engine/executors/__init__.py
Normal file
18
modules/workflowAutomation/engine/executors/__init__.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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"),
|
||||
|
|
@ -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__)
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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()))
|
||||
|
||||
|
|
@ -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__)
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
@ -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__)
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
11
modules/workflowAutomation/scheduler/__init__.py
Normal file
11
modules/workflowAutomation/scheduler/__init__.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue