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:
|
try:
|
||||||
main_loop = asyncio.get_running_loop()
|
main_loop = asyncio.get_running_loop()
|
||||||
eventManager.set_event_loop(main_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)
|
setSchedulerMainLoop(main_loop)
|
||||||
|
|
||||||
# Inject run-failed notification callback (Composition Root — avoids workflows→serviceCenter import)
|
# Inject run-failed notification callback (Composition Root — avoids workflows→serviceCenter import)
|
||||||
|
|
@ -452,10 +452,10 @@ async def lifespan(app: FastAPI):
|
||||||
user=eventUser,
|
user=eventUser,
|
||||||
mandate_id=mandateId or "",
|
mandate_id=mandateId or "",
|
||||||
feature_instance_id="",
|
feature_instance_id="",
|
||||||
feature_code="graphicalEditor",
|
feature_code="workflowAutomation",
|
||||||
)
|
)
|
||||||
messagingService = getService("messaging", ctx)
|
messagingService = getService("messaging", ctx)
|
||||||
subscriptionId = "GraphicalEditorRunFailed"
|
subscriptionId = "WorkflowAutomationRunFailed"
|
||||||
eventParams = MessagingEventParameters(triggerData={
|
eventParams = MessagingEventParameters(triggerData={
|
||||||
"workflowId": workflowId,
|
"workflowId": workflowId,
|
||||||
"workflowLabel": workflowLabel or workflowId,
|
"workflowLabel": workflowLabel or workflowId,
|
||||||
|
|
@ -484,7 +484,7 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
# --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) ---
|
# --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) ---
|
||||||
try:
|
try:
|
||||||
from modules.workflows.scheduler.mainScheduler import start as _startWorkflowScheduler
|
from modules.workflowAutomation.scheduler.mainScheduler import start as _startWorkflowScheduler
|
||||||
_startWorkflowScheduler(eventUser)
|
_startWorkflowScheduler(eventUser)
|
||||||
logger.info("WorkflowAutomation scheduler started (system lifespan)")
|
logger.info("WorkflowAutomation scheduler started (system lifespan)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -572,12 +572,12 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
# 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan)
|
# 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan)
|
||||||
try:
|
try:
|
||||||
from modules.workflows.scheduler.mainScheduler import stop as _stopWorkflowScheduler
|
from modules.workflowAutomation.scheduler.mainScheduler import stop as _stopWorkflowScheduler
|
||||||
_stopWorkflowScheduler()
|
_stopWorkflowScheduler()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"WorkflowAutomation scheduler stop failed: {e}")
|
logger.warning(f"WorkflowAutomation scheduler stop failed: {e}")
|
||||||
try:
|
try:
|
||||||
from modules.features.graphicalEditor.emailPoller import stop as _stopEmailPoller
|
from modules.workflowAutomation.scheduler.emailPoller import stop as _stopEmailPoller
|
||||||
_stopEmailPoller(eventUser)
|
_stopEmailPoller(eventUser)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Email poller stop failed: {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(systemRouter)
|
||||||
app.include_router(navigationRouter)
|
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
|
from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter
|
||||||
app.include_router(workflowAutomationRouter)
|
app.include_router(workflowAutomationRouter)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ class ChatWorkflow(PowerOnModel):
|
||||||
None,
|
None,
|
||||||
description=(
|
description=(
|
||||||
"Optional foreign key linking this chat to an entity outside the "
|
"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 "
|
"AI editor chat). NULL for the default workspace chats. Combined with "
|
||||||
"featureInstanceId this gives a 1:1 relation entity ↔ chat per feature."
|
"featureInstanceId this gives a 1:1 relation entity ↔ chat per feature."
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -120,14 +120,6 @@ NAVIGATION_SECTIONS = [
|
||||||
"path": "/billing/transactions",
|
"path": "/billing/transactions",
|
||||||
"order": 20,
|
"order": 20,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "automations",
|
|
||||||
"objectKey": "ui.system.automations",
|
|
||||||
"label": t("Automations"),
|
|
||||||
"icon": "FaRobot",
|
|
||||||
"path": "/automations",
|
|
||||||
"order": 30,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "rag-inventory",
|
"id": "rag-inventory",
|
||||||
"objectKey": "ui.system.ragInventory",
|
"objectKey": "ui.system.ragInventory",
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ _USER = {
|
||||||
_FEATURES_HAPPYLIFE = [
|
_FEATURES_HAPPYLIFE = [
|
||||||
{"code": "workspace", "label": "Dokumentenablage"},
|
{"code": "workspace", "label": "Dokumentenablage"},
|
||||||
{"code": "trustee", "label": "Buchhaltung"},
|
{"code": "trustee", "label": "Buchhaltung"},
|
||||||
{"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component
|
|
||||||
{"code": "neutralization", "label": "Datenschutz"},
|
{"code": "neutralization", "label": "Datenschutz"},
|
||||||
]
|
]
|
||||||
_FEATURES_ALPINA = [
|
_FEATURES_ALPINA = [
|
||||||
|
|
@ -52,7 +51,6 @@ _FEATURES_ALPINA = [
|
||||||
{"code": "trustee", "label": "BUHA Müller Immobilien GmbH"},
|
{"code": "trustee", "label": "BUHA Müller Immobilien GmbH"},
|
||||||
{"code": "trustee", "label": "BUHA Schneider Gastro AG"},
|
{"code": "trustee", "label": "BUHA Schneider Gastro AG"},
|
||||||
{"code": "trustee", "label": "BUHA Weber Consulting"},
|
{"code": "trustee", "label": "BUHA Weber Consulting"},
|
||||||
{"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component
|
|
||||||
{"code": "neutralization", "label": "Datenschutz"},
|
{"code": "neutralization", "label": "Datenschutz"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -492,8 +490,8 @@ class InvestorDemo2026(BaseDemoConfig):
|
||||||
if not instId:
|
if not instId:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if featureCode == "graphicalEditor":
|
if featureCode == "workflowAutomation":
|
||||||
self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary)
|
self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary)
|
||||||
|
|
||||||
if featureCode == "trustee":
|
if featureCode == "trustee":
|
||||||
self._removeTrusteeData(db, instId, mandateLabel, summary)
|
self._removeTrusteeData(db, instId, mandateLabel, summary)
|
||||||
|
|
@ -551,10 +549,10 @@ class InvestorDemo2026(BaseDemoConfig):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
summary["errors"].append(f"Billing cleanup for {mandateLabel}: {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):
|
||||||
"""Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the Greenfield DB."""
|
"""Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the WorkflowAutomation DB."""
|
||||||
try:
|
try:
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
from modules.datamodels.datamodelWorkflowAutomation import (
|
||||||
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
|
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
|
||||||
)
|
)
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
|
@ -596,10 +594,10 @@ class InvestorDemo2026(BaseDemoConfig):
|
||||||
|
|
||||||
if workflows:
|
if workflows:
|
||||||
summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}")
|
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:
|
except Exception as e:
|
||||||
summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}")
|
summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}")
|
||||||
logger.error(f"Failed to clean up graphical editor data 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):
|
def _removeTrusteeData(self, db, featureInstanceId: str, mandateLabel: str, summary: Dict):
|
||||||
"""Remove TrusteeAccountingConfig for a feature instance."""
|
"""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 mandate "Stiftung PWG"
|
||||||
- 1 SysAdmin demo user "pwg.demo"
|
- 1 SysAdmin demo user "pwg.demo"
|
||||||
- 4 features: workspace, trustee (BUHA PWG), graphicalEditor (PWG Automationen),
|
- 3 features: workspace, trustee (BUHA PWG), neutralization (Datenschutz)
|
||||||
neutralization (Datenschutz)
|
|
||||||
- Trustee seed-data (5 fictitious tenants with monthly rent journal lines for
|
- Trustee seed-data (5 fictitious tenants with monthly rent journal lines for
|
||||||
the current year, loaded from ``demoData/pwg/_seedTrusteeData.json``)
|
the current year, loaded from ``demoData/pwg/_seedTrusteeData.json``)
|
||||||
- Pilot workflow imported from
|
- Pilot workflow imported from
|
||||||
|
|
@ -49,7 +48,6 @@ _USER = {
|
||||||
_FEATURES_PWG = [
|
_FEATURES_PWG = [
|
||||||
{"code": "workspace", "label": "Dokumentenablage PWG"},
|
{"code": "workspace", "label": "Dokumentenablage PWG"},
|
||||||
{"code": "trustee", "label": "Buchhaltung PWG"},
|
{"code": "trustee", "label": "Buchhaltung PWG"},
|
||||||
{"code": "graphicalEditor", "label": "PWG Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component
|
|
||||||
{"code": "neutralization", "label": "Datenschutz"},
|
{"code": "neutralization", "label": "Datenschutz"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -98,9 +96,6 @@ class PwgDemo2026(BaseDemoConfig):
|
||||||
if trusteeInstanceId:
|
if trusteeInstanceId:
|
||||||
self._ensureTrusteeSeed(mandateId, trusteeInstanceId, summary)
|
self._ensureTrusteeSeed(mandateId, trusteeInstanceId, summary)
|
||||||
|
|
||||||
graphInstanceId = self._getFeatureInstanceId(db, mandateId, "graphicalEditor", "PWG Automationen")
|
|
||||||
if graphInstanceId:
|
|
||||||
self._ensurePilotWorkflow(mandateId, graphInstanceId, summary)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"PWG demo load failed: {e}", exc_info=True)
|
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")
|
summary["skipped"].append(f"PWG seed: {skippedTenants} tenants already present")
|
||||||
|
|
||||||
def _ensurePilotWorkflow(self, mandateId: str, featureInstanceId: str, summary: Dict):
|
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
|
Uses the schema-aware import pipeline introduced in Phase 1
|
||||||
(``_workflowFileSchema.envelopeToWorkflowData`` +
|
(``_workflowFileSchema.envelopeToWorkflowData`` +
|
||||||
``GraphicalEditorObjects.importWorkflowFromDict``). The workflow is
|
``WorkflowAutomationObjects.importWorkflowFromDict``). The workflow is
|
||||||
always created with ``active=False`` so a manual trigger is required
|
always created with ``active=False`` so a manual trigger is required
|
||||||
— this matches the demo-bootstrap safety default.
|
— this matches the demo-bootstrap safety default.
|
||||||
"""
|
"""
|
||||||
|
|
@ -561,17 +556,17 @@ class PwgDemo2026(BaseDemoConfig):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
geDb = _openGraphicalEditorDb()
|
geDb = _openWorkflowAutomationDb()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
summary["errors"].append(f"GraphicalEditor DB connection failed: {exc}")
|
summary["errors"].append(f"WorkflowAutomation DB connection failed: {exc}")
|
||||||
return
|
return
|
||||||
|
|
||||||
from modules.features.graphicalEditor._workflowFileSchema import (
|
from modules.workflowAutomation.editor._workflowFileSchema import (
|
||||||
envelopeToWorkflowData,
|
envelopeToWorkflowData,
|
||||||
validateFileEnvelope,
|
validateFileEnvelope,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow
|
||||||
from modules.features.graphicalEditor.nodeRegistry import STATIC_NODE_TYPES
|
from modules.workflowAutomation.editor.nodeRegistry import STATIC_NODE_TYPES
|
||||||
|
|
||||||
existing = geDb.getRecordset(AutoWorkflow, recordFilter={
|
existing = geDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
"mandateId": mandateId,
|
"mandateId": mandateId,
|
||||||
|
|
@ -625,7 +620,7 @@ class PwgDemo2026(BaseDemoConfig):
|
||||||
)
|
)
|
||||||
created = geDb.recordCreate(AutoWorkflow, record)
|
created = geDb.recordCreate(AutoWorkflow, record)
|
||||||
summary["created"].append(f"Pilot workflow imported (active=false, id={created.get('id')})")
|
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]:
|
def _guessTrusteeInstanceId(self, mandateId: str) -> Optional[str]:
|
||||||
"""Return the first trustee feature-instance id of the given mandate.
|
"""Return the first trustee feature-instance id of the given mandate.
|
||||||
|
|
@ -678,8 +673,8 @@ class PwgDemo2026(BaseDemoConfig):
|
||||||
if not instId:
|
if not instId:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if featureCode == "graphicalEditor":
|
if featureCode == "workflowAutomation":
|
||||||
self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary)
|
self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary)
|
||||||
if featureCode == "trustee":
|
if featureCode == "trustee":
|
||||||
self._removeTrusteeSeed(instId, mandateLabel, summary)
|
self._removeTrusteeSeed(instId, mandateLabel, summary)
|
||||||
if featureCode == "neutralization":
|
if featureCode == "neutralization":
|
||||||
|
|
@ -724,16 +719,16 @@ class PwgDemo2026(BaseDemoConfig):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
summary["errors"].append(f"Billing cleanup for {mandateLabel}: {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:
|
try:
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
from modules.datamodels.datamodelWorkflowAutomation import (
|
||||||
AutoRun,
|
AutoRun,
|
||||||
AutoStepLog,
|
AutoStepLog,
|
||||||
AutoTask,
|
AutoTask,
|
||||||
AutoVersion,
|
AutoVersion,
|
||||||
AutoWorkflow,
|
AutoWorkflow,
|
||||||
)
|
)
|
||||||
geDb = _openGraphicalEditorDb()
|
geDb = _openWorkflowAutomationDb()
|
||||||
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
|
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
"mandateId": mandateId,
|
"mandateId": mandateId,
|
||||||
"featureInstanceId": featureInstanceId,
|
"featureInstanceId": featureInstanceId,
|
||||||
|
|
@ -753,7 +748,7 @@ class PwgDemo2026(BaseDemoConfig):
|
||||||
if workflows:
|
if workflows:
|
||||||
summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}")
|
summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}")
|
||||||
except Exception as e:
|
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):
|
def _removeTrusteeSeed(self, featureInstanceId: str, mandateLabel: str, summary: Dict):
|
||||||
try:
|
try:
|
||||||
|
|
@ -818,7 +813,7 @@ def _openTrusteeDb():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _openGraphicalEditorDb():
|
def _openWorkflowAutomationDb():
|
||||||
"""Open a privileged DB connection to ``poweron_graphicaleditor``."""
|
"""Open a privileged DB connection to ``poweron_graphicaleditor``."""
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Mandate retention purge failed: {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
|
# Let features run their own bootstrap logic via lifecycle hooks
|
||||||
from modules.shared.featureDiscovery import loadFeatureMainModules
|
from modules.shared.featureDiscovery import loadFeatureMainModules
|
||||||
for _fCode, _fMod in loadFeatureMainModules().items():
|
for _fCode, _fMod in loadFeatureMainModules().items():
|
||||||
|
|
@ -1610,7 +1617,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
|
||||||
"resource.store.workspace",
|
"resource.store.workspace",
|
||||||
"resource.store.commcoach",
|
"resource.store.commcoach",
|
||||||
"resource.store.trustee",
|
"resource.store.trustee",
|
||||||
"resource.store.graphicalEditor", # DEPRECATED: will move with WorkflowAutomation code restructuring
|
"resource.store.workflowAutomation",
|
||||||
]
|
]
|
||||||
|
|
||||||
storeRules = []
|
storeRules = []
|
||||||
|
|
|
||||||
|
|
@ -1870,6 +1870,13 @@ class AppObjects:
|
||||||
|
|
||||||
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
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
|
# 0-pre. Let features cascade-delete their own data via lifecycle hooks
|
||||||
from modules.shared.featureDiscovery import loadFeatureMainModules
|
from modules.shared.featureDiscovery import loadFeatureMainModules
|
||||||
for _fCode, _fMod in loadFeatureMainModules().items():
|
for _fCode, _fMod in loadFeatureMainModules().items():
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@ class ChatObjects:
|
||||||
) -> Optional[ChatWorkflow]:
|
) -> Optional[ChatWorkflow]:
|
||||||
"""Return the ChatWorkflow linked to (featureInstanceId, linkedWorkflowId), if any.
|
"""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).
|
find the persisted chat for a specific external entity (Automation2Workflow).
|
||||||
Falls under the same RBAC as ``getWorkflow``.
|
Falls under the same RBAC as ``getWorkflow``.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -933,7 +933,7 @@ class ComponentObjects:
|
||||||
If pagination is provided: PaginatedResult with items and metadata
|
If pagination is provided: PaginatedResult with items and metadata
|
||||||
"""
|
"""
|
||||||
def _convertFileItems(files):
|
def _convertFileItems(files):
|
||||||
from modules.workflows.automation2.workflowArtifactVisibility import (
|
from modules.workflowAutomation.engine.workflowArtifactVisibility import (
|
||||||
suppress_workflow_file_in_workspace_ui,
|
suppress_workflow_file_in_workspace_ui,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -271,7 +271,7 @@ class FeatureInterface:
|
||||||
Copy feature-specific template workflows to a new instance.
|
Copy feature-specific template workflows to a new instance.
|
||||||
|
|
||||||
Loads TEMPLATE_WORKFLOWS from the feature module and creates
|
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}}
|
(mandateId, instanceId). The placeholder {{featureInstanceId}}
|
||||||
in graph parameters is replaced with the actual instanceId.
|
in graph parameters is replaced with the actual instanceId.
|
||||||
|
|
||||||
|
|
@ -321,14 +321,10 @@ class FeatureInterface:
|
||||||
f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})"
|
f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})"
|
||||||
)
|
)
|
||||||
|
|
||||||
geMod = mainModules.get("graphicalEditor")
|
from modules.workflowAutomation.mainWorkflowAutomation import onInstanceCreate as _waOnInstanceCreate
|
||||||
onInstanceCreateHook = getattr(geMod, "onInstanceCreate", None) if geMod else None
|
|
||||||
if not onInstanceCreateHook:
|
|
||||||
logger.warning("_copyTemplateWorkflows: graphicalEditor.onInstanceCreate hook not available")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
copied = onInstanceCreateHook(mandateId, instanceId, featureCode, templateWorkflows)
|
copied = _waOnInstanceCreate(mandateId, instanceId, featureCode, templateWorkflows)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"_copyTemplateWorkflows: onInstanceCreate hook failed for '{featureCode}': {e}",
|
f"_copyTemplateWorkflows: onInstanceCreate hook failed for '{featureCode}': {e}",
|
||||||
|
|
|
||||||
|
|
@ -204,16 +204,16 @@ TABLE_NAMESPACE = {
|
||||||
# Automation - benutzer-eigen
|
# Automation - benutzer-eigen
|
||||||
"AutomationDefinition": "automation",
|
"AutomationDefinition": "automation",
|
||||||
"AutomationTemplate": "automation",
|
"AutomationTemplate": "automation",
|
||||||
# GraphicalEditor - Greenfield DB poweron_graphicaleditor (Auto-prefix models)
|
# WorkflowAutomation - Greenfield DB poweron_graphicaleditor (Auto-prefix models)
|
||||||
"AutoWorkflow": "feature.graphicalEditor",
|
"AutoWorkflow": "system.workflowAutomation",
|
||||||
"AutoVersion": "feature.graphicalEditor",
|
"AutoVersion": "system.workflowAutomation",
|
||||||
"AutoRun": "feature.graphicalEditor",
|
"AutoRun": "system.workflowAutomation",
|
||||||
"AutoStepLog": "feature.graphicalEditor",
|
"AutoStepLog": "system.workflowAutomation",
|
||||||
"AutoTask": "feature.graphicalEditor",
|
"AutoTask": "system.workflowAutomation",
|
||||||
# Legacy aliases (backward compat)
|
# Legacy aliases (backward compat)
|
||||||
"Automation2Workflow": "feature.graphicalEditor",
|
"Automation2Workflow": "system.workflowAutomation",
|
||||||
"Automation2WorkflowRun": "feature.graphicalEditor",
|
"Automation2WorkflowRun": "system.workflowAutomation",
|
||||||
"Automation2HumanTask": "feature.graphicalEditor",
|
"Automation2HumanTask": "system.workflowAutomation",
|
||||||
# Knowledge Store - benutzer-eigen
|
# Knowledge Store - benutzer-eigen
|
||||||
"FileContentIndex": "knowledge",
|
"FileContentIndex": "knowledge",
|
||||||
"ContentChunk": "knowledge",
|
"ContentChunk": "knowledge",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Interface for GraphicalEditor feature - Workflows, Runs, Human Tasks.
|
Interface for WorkflowAutomation system component - Workflows, Runs, Human Tasks.
|
||||||
Uses PostgreSQL poweron_graphicaleditor database (Greenfield).
|
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
|
import base64
|
||||||
|
|
@ -47,33 +53,36 @@ from modules.datamodels.datamodelWorkflowAutomation import (
|
||||||
AutoStepLog,
|
AutoStepLog,
|
||||||
AutoTask,
|
AutoTask,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.dbHelpers.dbRegistry import registerDatabase
|
from modules.dbHelpers.dbRegistry import registerDatabase
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE
|
workflowAutomationDatabase = GRAPHICAL_EDITOR_DATABASE
|
||||||
registerDatabase(graphicalEditorDatabase)
|
registerDatabase(workflowAutomationDatabase)
|
||||||
_CALLBACK_WORKFLOW_CHANGED = "graphicalEditor.workflow.changed"
|
_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,
|
currentUser: User,
|
||||||
mandateId: str,
|
mandateId: str,
|
||||||
featureInstanceId: str,
|
featureInstanceId: str,
|
||||||
) -> "GraphicalEditorObjects":
|
) -> "WorkflowAutomationObjects":
|
||||||
"""Factory for GraphicalEditor interface with user context."""
|
"""Factory for WorkflowAutomation interface with user context."""
|
||||||
return GraphicalEditorObjects(
|
return WorkflowAutomationObjects(
|
||||||
currentUser=currentUser,
|
currentUser=currentUser,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=featureInstanceId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Backward-compatible alias used by workflows/automation2/ execution engine
|
|
||||||
getAutomation2Interface = getGraphicalEditorInterface
|
|
||||||
|
|
||||||
|
|
||||||
def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
|
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.
|
Used by the scheduler to register cron jobs. Does not filter by mandate/instance.
|
||||||
"""
|
"""
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
||||||
dbDatabase = graphicalEditorDatabase
|
dbDatabase = workflowAutomationDatabase
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
@ -95,7 +104,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
|
||||||
userId=None,
|
userId=None,
|
||||||
)
|
)
|
||||||
if not connector._ensureTableExists(AutoWorkflow):
|
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 []
|
return []
|
||||||
records = connector.getRecordset(
|
records = connector.getRecordset(
|
||||||
AutoWorkflow,
|
AutoWorkflow,
|
||||||
|
|
@ -107,7 +116,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
|
||||||
if r.get("active") is False:
|
if r.get("active") is False:
|
||||||
continue
|
continue
|
||||||
wf = dict(r)
|
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 []
|
invocations = wf.get("invocations") or []
|
||||||
primary = invocations[0] if invocations else {}
|
primary = invocations[0] if invocations else {}
|
||||||
if not isinstance(primary, dict):
|
if not isinstance(primary, dict):
|
||||||
|
|
@ -142,15 +151,15 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
|
||||||
"workflow": wf,
|
"workflow": wf,
|
||||||
})
|
})
|
||||||
logger.info(
|
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,
|
raw_count,
|
||||||
len(result),
|
len(result),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class GraphicalEditorObjects:
|
class WorkflowAutomationObjects:
|
||||||
"""Interface for GraphicalEditor database operations (Greenfield DB)."""
|
"""Interface for WorkflowAutomation database operations (poweron_graphicaleditor DB)."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -167,9 +176,9 @@ class GraphicalEditorObjects:
|
||||||
self.db.updateContext(self.userId)
|
self.db.updateContext(self.userId)
|
||||||
|
|
||||||
def _init_db(self):
|
def _init_db(self):
|
||||||
"""Initialize database connection to poweron_graphicaleditor (Greenfield)."""
|
"""Initialize database connection to poweron_graphicaleditor."""
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
||||||
dbDatabase = graphicalEditorDatabase
|
dbDatabase = workflowAutomationDatabase
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
@ -181,7 +190,7 @@ class GraphicalEditorObjects:
|
||||||
dbPort=dbPort,
|
dbPort=dbPort,
|
||||||
userId=self.userId,
|
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
|
# Workflow CRUD
|
||||||
|
|
@ -202,7 +211,7 @@ class GraphicalEditorObjects:
|
||||||
)
|
)
|
||||||
rows = [dict(r) for r in records] if records else []
|
rows = [dict(r) for r in records] if records else []
|
||||||
for wf in rows:
|
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
|
return rows
|
||||||
|
|
||||||
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]:
|
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
|
@ -219,7 +228,7 @@ class GraphicalEditorObjects:
|
||||||
if not records:
|
if not records:
|
||||||
return None
|
return None
|
||||||
wf = dict(records[0])
|
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
|
return wf
|
||||||
|
|
||||||
def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
|
@ -232,10 +241,10 @@ class GraphicalEditorObjects:
|
||||||
data["targetFeatureInstanceId"] = self.featureInstanceId
|
data["targetFeatureInstanceId"] = self.featureInstanceId
|
||||||
if "active" not in data or data.get("active") is None:
|
if "active" not in data or data.get("active") is None:
|
||||||
data["active"] = True
|
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)
|
created = self.db.recordCreate(AutoWorkflow, data)
|
||||||
out = dict(created)
|
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:
|
try:
|
||||||
from modules.shared.callbackRegistry import callbackRegistry
|
from modules.shared.callbackRegistry import callbackRegistry
|
||||||
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
|
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
|
||||||
|
|
@ -255,10 +264,10 @@ class GraphicalEditorObjects:
|
||||||
if not isinstance(g, dict):
|
if not isinstance(g, dict):
|
||||||
g = {}
|
g = {}
|
||||||
inv = data["invocations"] if "invocations" in data else existing.get("invocations")
|
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)
|
updated = self.db.recordModify(AutoWorkflow, workflowId, data)
|
||||||
out = dict(updated)
|
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:
|
try:
|
||||||
from modules.shared.callbackRegistry import callbackRegistry
|
from modules.shared.callbackRegistry import callbackRegistry
|
||||||
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
|
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
|
||||||
|
|
@ -683,7 +692,7 @@ class GraphicalEditorObjects:
|
||||||
envelope) and can be JSON-serialized as-is. Returns ``None`` if the
|
envelope) and can be JSON-serialized as-is. Returns ``None`` if the
|
||||||
workflow does not exist for this mandate.
|
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)
|
wf = self.getWorkflow(workflowId)
|
||||||
if not wf:
|
if not wf:
|
||||||
|
|
@ -702,11 +711,11 @@ class GraphicalEditorObjects:
|
||||||
``existingWorkflowId`` is given. Imports are always saved with
|
``existingWorkflowId`` is given. Imports are always saved with
|
||||||
``active=False`` so operators can review before scheduling.
|
``active=False`` so operators can review before scheduling.
|
||||||
"""
|
"""
|
||||||
from modules.features.graphicalEditor._workflowFileSchema import (
|
from modules.workflowAutomation.editor._workflowFileSchema import (
|
||||||
envelopeToWorkflowData,
|
envelopeToWorkflowData,
|
||||||
validateFileEnvelope,
|
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")]
|
knownTypes = [n.get("id") for n in STATIC_NODE_TYPES if isinstance(n, dict) and n.get("id")]
|
||||||
normalizedEnvelope, warnings = validateFileEnvelope(envelope, knownNodeTypes=knownTypes)
|
normalizedEnvelope, warnings = validateFileEnvelope(envelope, knownNodeTypes=knownTypes)
|
||||||
|
|
@ -728,6 +737,3 @@ class GraphicalEditorObjects:
|
||||||
created = self.createWorkflow(data)
|
created = self.createWorkflow(data)
|
||||||
return {"workflow": created, "warnings": warnings, "created": True}
|
return {"workflow": created, "warnings": warnings, "created": True}
|
||||||
|
|
||||||
|
|
||||||
# Backward-compatible alias
|
|
||||||
Automation2Objects = GraphicalEditorObjects
|
|
||||||
|
|
@ -913,11 +913,11 @@ def _syncInstanceWorkflows(
|
||||||
if not templateWorkflows:
|
if not templateWorkflows:
|
||||||
return SyncWorkflowsResult(added=0, skipped=0, total=0)
|
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
|
from modules.security.rootAccess import getRootUser
|
||||||
|
|
||||||
rootUser = getRootUser()
|
rootUser = getRootUser()
|
||||||
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
|
geInterface = _getWorkflowAutomationInterface(rootUser, mandateId, instanceId)
|
||||||
|
|
||||||
existingWorkflows = geInterface.getWorkflows() or []
|
existingWorkflows = geInterface.getWorkflows() or []
|
||||||
existingSourceIds = set()
|
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:
|
except Exception as e:
|
||||||
logger.debug(f"integrations-overview billing stats: {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:
|
try:
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
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,
|
default=False,
|
||||||
description=(
|
description=(
|
||||||
"If True, do NOT register workflow-action methods as agent tools. "
|
"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."
|
"manipulate the workflow graph, not execute its actions."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ def _registerDefaultToolboxes() -> None:
|
||||||
id="workflow",
|
id="workflow",
|
||||||
label="Workflow",
|
label="Workflow",
|
||||||
description="Graph manipulation tools for the visual editor",
|
description="Graph manipulation tools for the visual editor",
|
||||||
featureCode="graphicalEditor",
|
featureCode="workflowAutomation",
|
||||||
isDefault=False,
|
isDefault=False,
|
||||||
tools=[
|
tools=[
|
||||||
"readWorkflowGraph", "addNode", "removeNode", "connectNodes",
|
"readWorkflowGraph", "addNode", "removeNode", "connectNodes",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# 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,
|
Tools: readWorkflowGraph, addNode, removeNode, connectNodes, setNodeParameter,
|
||||||
listAvailableNodeTypes, describeNodeType, autoLayoutWorkflow,
|
listAvailableNodeTypes, describeNodeType, autoLayoutWorkflow,
|
||||||
validateGraph, listWorkflowHistory, readWorkflowMessages.
|
validateGraph, listWorkflowHistory, readWorkflowMessages.
|
||||||
|
|
@ -89,9 +89,8 @@ def _resolveMandateId(context: Any) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _getInterface(context: Any, instanceId: str):
|
def _getInterface(context: Any, instanceId: str):
|
||||||
# DEPRECATED: will move with WorkflowAutomation code restructuring
|
from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
return _getWorkflowAutomationInterface(_resolveUser(context), _resolveMandateId(context), instanceId)
|
||||||
return getGraphicalEditorInterface(_resolveUser(context), _resolveMandateId(context), instanceId)
|
|
||||||
|
|
||||||
|
|
||||||
async def _readWorkflowGraph(params: Dict[str, Any], context: Any) -> ToolResult:
|
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")
|
return _err(name, f"Workflow {workflow_id} not found")
|
||||||
|
|
||||||
graph = wf.get("graph", {}) or {}
|
graph = wf.get("graph", {}) or {}
|
||||||
# DEPRECATED: will move with WorkflowAutomation code restructuring
|
from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths
|
||||||
from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths
|
|
||||||
|
|
||||||
paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(node_id))
|
paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(node_id))
|
||||||
return _ok(name, {"paths": paths})
|
return _ok(name, {"paths": paths})
|
||||||
|
|
@ -438,8 +436,7 @@ async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolR
|
||||||
"""
|
"""
|
||||||
name = "listAvailableNodeTypes"
|
name = "listAvailableNodeTypes"
|
||||||
try:
|
try:
|
||||||
# DEPRECATED: will move with WorkflowAutomation code restructuring
|
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
|
||||||
nodeTypes = []
|
nodeTypes = []
|
||||||
for n in STATIC_NODE_TYPES:
|
for n in STATIC_NODE_TYPES:
|
||||||
if not isinstance(n, dict):
|
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")
|
nodeType = params.get("nodeType") or params.get("id")
|
||||||
if not nodeType:
|
if not nodeType:
|
||||||
return _err(name, "nodeType required")
|
return _err(name, "nodeType required")
|
||||||
# DEPRECATED: will move with WorkflowAutomation code restructuring
|
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
|
||||||
target: Dict[str, Any] = {}
|
target: Dict[str, Any] = {}
|
||||||
for n in STATIC_NODE_TYPES:
|
for n in STATIC_NODE_TYPES:
|
||||||
if isinstance(n, dict) and n.get("id") == nodeType:
|
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)
|
envelope = iface.exportWorkflowToDict(workflowId)
|
||||||
if envelope is None:
|
if envelope is None:
|
||||||
return _err(name, f"Workflow {workflowId} not found")
|
return _err(name, f"Workflow {workflowId} not found")
|
||||||
# DEPRECATED: will move with WorkflowAutomation code restructuring
|
from modules.workflowAutomation.editor._workflowFileSchema import buildFileName
|
||||||
from modules.features.graphicalEditor._workflowFileSchema import buildFileName
|
|
||||||
return _ok(name, {
|
return _ok(name, {
|
||||||
"fileName": buildFileName(envelope.get("label", "workflow")),
|
"fileName": buildFileName(envelope.get("label", "workflow")),
|
||||||
"envelope": envelope,
|
"envelope": envelope,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# 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.
|
Sends email notifications to subscribed users when a workflow run fails.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ def execute(
|
||||||
messagingService,
|
messagingService,
|
||||||
) -> MessagingSubscriptionExecutionResult:
|
) -> MessagingSubscriptionExecutionResult:
|
||||||
"""
|
"""
|
||||||
Subscription function for GraphicalEditor run failures.
|
Subscription function for WorkflowAutomation run failures.
|
||||||
Sends email/SMS to registered users when a workflow run fails.
|
Sends email/SMS to registered users when a workflow run fails.
|
||||||
"""
|
"""
|
||||||
triggerData = eventParameters.triggerData or {}
|
triggerData = eventParameters.triggerData or {}
|
||||||
|
|
@ -40,7 +40,7 @@ def execute(
|
||||||
f"Workflow-ID: {workflowId}\n"
|
f"Workflow-ID: {workflowId}\n"
|
||||||
f"Run-ID: {runId}\n"
|
f"Run-ID: {runId}\n"
|
||||||
f"Fehler: {error}\n\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]}"
|
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 = (
|
_featureModulePaths = (
|
||||||
"modules.features.trustee.mainTrustee",
|
"modules.features.trustee.mainTrustee",
|
||||||
"modules.features.graphicalEditor.mainGraphicalEditor",
|
"modules.workflowAutomation.mainWorkflowAutomation",
|
||||||
"modules.features.commcoach.mainCommcoach",
|
"modules.features.commcoach.mainCommcoach",
|
||||||
"modules.features.teamsbot.mainTeamsbot",
|
"modules.features.teamsbot.mainTeamsbot",
|
||||||
"modules.features.workspace.mainWorkspace",
|
"modules.features.workspace.mainWorkspace",
|
||||||
|
|
@ -150,7 +150,7 @@ def _registerRbacLabels():
|
||||||
_featureModulePaths = (
|
_featureModulePaths = (
|
||||||
"modules.system.mainSystem",
|
"modules.system.mainSystem",
|
||||||
"modules.features.trustee.mainTrustee",
|
"modules.features.trustee.mainTrustee",
|
||||||
"modules.features.graphicalEditor.mainGraphicalEditor",
|
"modules.workflowAutomation.mainWorkflowAutomation",
|
||||||
"modules.features.commcoach.mainCommcoach",
|
"modules.features.commcoach.mainCommcoach",
|
||||||
"modules.features.teamsbot.mainTeamsbot",
|
"modules.features.teamsbot.mainTeamsbot",
|
||||||
"modules.features.workspace.mainWorkspace",
|
"modules.features.workspace.mainWorkspace",
|
||||||
|
|
@ -242,8 +242,7 @@ def _registerNodeLabels():
|
||||||
added += 1
|
added += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# DEPRECATED: will move with WorkflowAutomation code restructuring
|
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
|
||||||
for nd in STATIC_NODE_TYPES:
|
for nd in STATIC_NODE_TYPES:
|
||||||
_reg(_extractRegistrySourceText(nd.get("label")), "node.label")
|
_reg(_extractRegistrySourceText(nd.get("label")), "node.label")
|
||||||
_reg(_extractRegistrySourceText(nd.get("description")), "node.desc")
|
_reg(_extractRegistrySourceText(nd.get("description")), "node.desc")
|
||||||
|
|
@ -266,8 +265,7 @@ def _registerNodeLabels():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# DEPRECATED: will move with WorkflowAutomation code restructuring
|
from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG
|
||||||
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG
|
|
||||||
for schema in PORT_TYPE_CATALOG.values():
|
for schema in PORT_TYPE_CATALOG.values():
|
||||||
for field in getattr(schema, "fields", []) or []:
|
for field in getattr(schema, "fields", []) or []:
|
||||||
desc = getattr(field, "description", None)
|
desc = getattr(field, "description", None)
|
||||||
|
|
|
||||||
|
|
@ -178,9 +178,9 @@ RESOURCE_OBJECTS = [
|
||||||
"meta": {"category": "store", "featureCode": "trustee"}
|
"meta": {"category": "store", "featureCode": "trustee"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "resource.store.graphicalEditor",
|
"objectKey": "resource.store.workflowAutomation",
|
||||||
"label": t("Store: Workflow-Automation", context="UI"),
|
"label": t("Store: Workflow-Automation", context="UI"),
|
||||||
"meta": {"category": "store", "featureCode": "graphicalEditor"}
|
"meta": {"category": "store", "featureCode": "workflowAutomation"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "resource.system.api.auth",
|
"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
|
# Copyright (c) 2026 Patrick Motsch
|
||||||
# All rights reserved.
|
# 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
|
A *workflow file* is a portable JSON representation of an ``AutoWorkflow`` that
|
||||||
can be exchanged between mandates / instances / installations. It contains the
|
can be exchanged between mandates / instances / installations. It contains the
|
||||||
|
|
@ -244,7 +244,7 @@ def envelopeToWorkflowData(
|
||||||
featureInstanceId: str,
|
featureInstanceId: str,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Convert a validated workflow-file envelope into a dict suitable for
|
"""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.
|
Imports are always inactive — operators must explicitly activate them.
|
||||||
Persistence-bound fields are NEVER copied from the envelope.
|
Persistence-bound fields are NEVER copied from the envelope.
|
||||||
|
|
@ -26,7 +26,7 @@ from __future__ import annotations
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Mapping
|
from typing import Any, Dict, List, Mapping
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeAdapter import (
|
from modules.workflowAutomation.editor.nodeAdapter import (
|
||||||
NodeAdapter,
|
NodeAdapter,
|
||||||
_adapterFromLegacyNode,
|
_adapterFromLegacyNode,
|
||||||
_isMethodBoundNode,
|
_isMethodBoundNode,
|
||||||
|
|
@ -8,7 +8,7 @@ import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
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
|
from modules.shared.i18nRegistry import resolveText, t
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -282,7 +282,7 @@ def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upst
|
||||||
return "file"
|
return "file"
|
||||||
|
|
||||||
if not _skip_upstream:
|
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
|
target_id = graph.get("targetNodeId") or producer_id
|
||||||
matched_type: Optional[str] = None
|
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
|
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.
|
(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 {}
|
g = graph if isinstance(graph, dict) else {}
|
||||||
nodes = g.get("nodes") or []
|
nodes = g.get("nodes") or []
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
|
from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import (
|
||||||
CONTEXT_BUILDER_PARAM_DESCRIPTION,
|
CONTEXT_BUILDER_PARAM_DESCRIPTION,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.flow import (
|
from modules.workflowAutomation.editor.nodeDefinitions.flow import (
|
||||||
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
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 = [
|
TASK_LIST_DATA_PICK_OPTIONS = [
|
||||||
{
|
{
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
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_ENVELOPE_DATA_PICK_OPTIONS,
|
||||||
CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS,
|
CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS,
|
||||||
)
|
)
|
||||||
|
|
@ -245,7 +245,7 @@ CONTEXT_NODES = [
|
||||||
"description": t(
|
"description": t(
|
||||||
"Filtert fuer die Presentation-Schicht nach typeGroup/MIME "
|
"Filtert fuer die Presentation-Schicht nach typeGroup/MIME "
|
||||||
"(gilt fuer alle Dokumenttypen analog, nicht nur PDF). "
|
"(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": {
|
"outputPorts": {
|
||||||
0: {
|
0: {
|
||||||
"schema": "ActionResult",
|
"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"],
|
"primaryTextRefPath": ["data"],
|
||||||
# Authoritative DataPicker paths (same idea as ``parameters`` for configuration).
|
|
||||||
# Frontend uses only this list — no schema expansion merge for this port.
|
|
||||||
"dataPickOptions": [
|
"dataPickOptions": [
|
||||||
{
|
{
|
||||||
"path": ["data"],
|
"path": ["data"],
|
||||||
|
|
@ -320,7 +315,6 @@ CONTEXT_NODES = [
|
||||||
"meta": {"icon": "mdi-file-tree-outline", "color": "#00897B", "usesAi": False},
|
"meta": {"icon": "mdi-file-tree-outline", "color": "#00897B", "usesAi": False},
|
||||||
"_method": "context",
|
"_method": "context",
|
||||||
"_action": "extractContent",
|
"_action": "extractContent",
|
||||||
# Executor behaviour flags — drives actionNodeExecutor without hardcoded type checks.
|
|
||||||
"skipUnifiedPresentation": True,
|
"skipUnifiedPresentation": True,
|
||||||
"clearResponse": True,
|
"clearResponse": True,
|
||||||
"imageDocumentsFromExtractData": True,
|
"imageDocumentsFromExtractData": True,
|
||||||
|
|
@ -356,14 +350,10 @@ CONTEXT_NODES = [
|
||||||
0: {"schema": "ActionResult", "dataPickOptions": CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS}
|
0: {"schema": "ActionResult", "dataPickOptions": CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS}
|
||||||
},
|
},
|
||||||
"injectUpstreamPayload": True,
|
"injectUpstreamPayload": True,
|
||||||
# Same contract as transformContext: picker paths like ``merged`` / ``first`` must match
|
|
||||||
# ``nodeOutputs`` (see actionNodeExecutor ``surfaceDataAsTopLevel``); merge payloads live in ``data``.
|
|
||||||
"surfaceDataAsTopLevel": True,
|
"surfaceDataAsTopLevel": True,
|
||||||
"meta": {"icon": "mdi-call-merge", "color": "#7B1FA2", "usesAi": False},
|
"meta": {"icon": "mdi-call-merge", "color": "#7B1FA2", "usesAi": False},
|
||||||
"_method": "context",
|
"_method": "context",
|
||||||
"_action": "mergeContext",
|
"_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,
|
"imageDocumentsFromMerged": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -433,8 +423,6 @@ CONTEXT_NODES = [
|
||||||
"deriveFrom": "mappings",
|
"deriveFrom": "mappings",
|
||||||
"deriveNameField": "outputField",
|
"deriveNameField": "outputField",
|
||||||
"dataPickOptions": CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
"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",
|
"fromGraphResultSchema": "ActionResult",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -4,14 +4,14 @@
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
CONTEXT_BUILDER_PARAM_DESCRIPTION = 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. "
|
"Handover-Pfade für strukturiertes JSON oder Medienlisten. "
|
||||||
"Die Auflösung erfolgt vollständig serverseitig (`resolveParameterReferences`). "
|
"Die Auflösung erfolgt vollständig serverseitig (`resolveParameterReferences`). "
|
||||||
"Formular-Schritte speichern Antworten unter „payload“ — fehlt ein gewählter Pfad am Root, "
|
"Formular-Schritte speichern Antworten unter „payload" — fehlt ein gewählter Pfad am Root, "
|
||||||
"wird derselbe Pfad automatisch unter „payload“ nachgeschlagen (Kompatibilität mit älteren "
|
"wird derselbe Pfad automatisch unter „payload" nachgeschlagen (Kompatibilität mit älteren "
|
||||||
"und neuen Picker-Pfaden). "
|
"und neuen Picker-Pfaden). "
|
||||||
"In Freitext-/Template-Feldern werden weiterhin Platzhalter `{{KnotenId.feld.b.z.}}` ersetzt "
|
"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
|
# Kurzreferenz für Node-Beschreibungen (optional einbinden): dieselbe Auflösungslogik
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
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 = [
|
AGGREGATE_RESULT_DATA_PICK_OPTIONS = [
|
||||||
{
|
{
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
|
from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import (
|
||||||
CONTEXT_BUILDER_PARAM_DESCRIPTION,
|
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 = [
|
EMAIL_LIST_DATA_PICK_OPTIONS = [
|
||||||
{
|
{
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
|
from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import (
|
||||||
CONTEXT_BUILDER_PARAM_DESCRIPTION,
|
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 = [
|
FILE_NODES = [
|
||||||
{
|
{
|
||||||
|
|
@ -14,7 +14,7 @@ FILE_NODES = [
|
||||||
"category": "file",
|
"category": "file",
|
||||||
"label": t("Datei erstellen"),
|
"label": t("Datei erstellen"),
|
||||||
"description": t(
|
"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."
|
"(``data`` oder Schleifen-``bodyResults``). Ausgabe über den Generation-Service."
|
||||||
),
|
),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
|
@ -37,7 +37,6 @@ FILE_NODES = [
|
||||||
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False},
|
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False},
|
||||||
"_method": "file",
|
"_method": "file",
|
||||||
"_action": "create",
|
"_action": "create",
|
||||||
# Emit a debug log tracing how the ``context`` parameter was resolved.
|
|
||||||
"logContextResolution": True,
|
"logContextResolution": True,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -96,7 +96,7 @@ MERGE_RESULT_DATA_PICK_OPTIONS = [
|
||||||
{
|
{
|
||||||
"path": ["first"],
|
"path": ["first"],
|
||||||
"pickerLabel": t("Erster Zweig"),
|
"pickerLabel": t("Erster Zweig"),
|
||||||
"detail": t("Daten vom ersten verbundenen Eingang (Modus „first“)."),
|
"detail": t("Daten vom ersten verbundenen Eingang (Modus „first")."),
|
||||||
"recommended": False,
|
"recommended": False,
|
||||||
"type": "Any",
|
"type": "Any",
|
||||||
},
|
},
|
||||||
|
|
@ -243,9 +243,9 @@ FLOW_NODES = [
|
||||||
"category": "flow",
|
"category": "flow",
|
||||||
"label": t("Schleife / Für jedes"),
|
"label": t("Schleife / Für jedes"),
|
||||||
"description": t(
|
"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). "
|
"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."
|
"Die zu durchlaufende Liste wählen Sie wie bisher; UDM-/Strukturdaten werden automatisch sinnvoll in Elemente aufgelöst."
|
||||||
),
|
),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
|
@ -266,7 +266,7 @@ FLOW_NODES = [
|
||||||
},
|
},
|
||||||
"description": t(
|
"description": t(
|
||||||
"Welche Elemente die Schleife besucht: alle, nur das erste/letzte, jedes zweite/dritte "
|
"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",
|
"default": "all",
|
||||||
},
|
},
|
||||||
|
|
@ -276,7 +276,7 @@ FLOW_NODES = [
|
||||||
"required": False,
|
"required": False,
|
||||||
"frontendType": "number",
|
"frontendType": "number",
|
||||||
"frontendOptions": {"min": 2, "max": 100},
|
"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,
|
"default": 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -333,12 +333,6 @@ FLOW_NODES = [
|
||||||
"default": 2,
|
"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,
|
"inputs": 2,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {
|
"inputPorts": {
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
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 = [
|
BOOL_RESULT_DATA_PICK_OPTIONS = [
|
||||||
{
|
{
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
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`).
|
# Typed FeatureInstance binding (replaces legacy `string, hidden`).
|
||||||
# - type FeatureInstanceRef[redmine] is filtered by the DataPicker.
|
# - type FeatureInstanceRef[redmine] is filtered by the DataPicker.
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
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,
|
ACTION_RESULT_DATA_PICK_OPTIONS,
|
||||||
DOCUMENT_LIST_DATA_PICK_OPTIONS,
|
DOCUMENT_LIST_DATA_PICK_OPTIONS,
|
||||||
)
|
)
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
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 = [
|
TRIGGER_NODES = [
|
||||||
{
|
{
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
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`).
|
# Typed FeatureInstance binding (replaces legacy `string, hidden`).
|
||||||
# - type uses the discriminator notation `FeatureInstanceRef[<code>]` so the
|
# - type uses the discriminator notation `FeatureInstanceRef[<code>]` so the
|
||||||
|
|
@ -61,9 +61,6 @@ TRUSTEE_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "AiResult", "LoopItem", "ActionResult"]}},
|
"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}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50", "usesAi": True},
|
"meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50", "usesAi": True},
|
||||||
"_method": "trustee",
|
"_method": "trustee",
|
||||||
|
|
@ -75,9 +72,6 @@ TRUSTEE_NODES = [
|
||||||
"label": t("Dokumente verarbeiten"),
|
"label": t("Dokumente verarbeiten"),
|
||||||
"description": t("TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen."),
|
"description": t("TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen."),
|
||||||
"parameters": [
|
"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",
|
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten"),
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# 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.
|
Nodes are defined first; IO/method actions are used at execution time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
from modules.features.graphicalEditor.conditionOperators import localize_operator_catalog
|
from modules.workflowAutomation.editor.conditionOperators import localize_operator_catalog
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
|
from modules.workflowAutomation.editor.nodeDefinitions.input import FORM_FIELD_TYPES
|
||||||
from modules.features.graphicalEditor.nodeAdapter import bindsActionFromLegacy
|
from modules.workflowAutomation.editor.nodeAdapter import bindsActionFromLegacy
|
||||||
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES
|
from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES
|
||||||
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText
|
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -178,7 +178,7 @@ def validateAdaptersAgainstMethods(methodInstances: Optional[Dict[str, Any]] = N
|
||||||
Pass `methodInstances` directly for testability; defaults to importing
|
Pass `methodInstances` directly for testability; defaults to importing
|
||||||
the live registry from `methodDiscovery.methods`.
|
the live registry from `methodDiscovery.methods`.
|
||||||
"""
|
"""
|
||||||
from modules.features.graphicalEditor.adapterValidator import (
|
from modules.workflowAutomation.editor.adapterValidator import (
|
||||||
_buildActionsRegistryFromMethods,
|
_buildActionsRegistryFromMethods,
|
||||||
_formatAdapterReport,
|
_formatAdapterReport,
|
||||||
_validateAllAdapters,
|
_validateAllAdapters,
|
||||||
|
|
@ -418,7 +418,7 @@ def deriveFormPayloadSchemaFromParam(
|
||||||
- Group-fields: ``type == "group"`` recursed via ``fields``.
|
- Group-fields: ``type == "group"`` recursed via ``fields``.
|
||||||
- List[str]: each string is taken as a leaf path key (used for ``filterContext.keys``).
|
- 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}
|
_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)
|
fields_param = (node.get("parameters") or {}).get(param_key)
|
||||||
|
|
@ -7,7 +7,7 @@ import copy
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional
|
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"})
|
_CONTEXT_FILTER_OPERATORS = frozenset({"contains_content"})
|
||||||
_BLOB_IMAGE_CHUNK_RE = re.compile(r"^\[image(?:\:([^\]]+))?\]$")
|
_BLOB_IMAGE_CHUNK_RE = re.compile(r"^\[image(?:\:([^\]]+))?\]$")
|
||||||
|
|
@ -4,10 +4,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List, Set
|
from typing import Any, Dict, List, Set
|
||||||
|
|
||||||
from modules.features.graphicalEditor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind
|
from modules.workflowAutomation.editor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema
|
from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema
|
||||||
from modules.workflows.automation2.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds
|
from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds
|
||||||
|
|
||||||
_NODE_BY_TYPE = {n["id"]: n for n in STATIC_NODE_TYPES}
|
_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 datetime import datetime, timezone
|
||||||
from typing import Dict, Any, List, Set, Optional
|
from typing import Dict, Any, List, Set, Optional
|
||||||
|
|
||||||
from modules.workflows.automation2.graphUtils import (
|
from modules.workflowAutomation.engine.graphUtils import (
|
||||||
parseGraph,
|
parseGraph,
|
||||||
buildConnectionMap,
|
buildConnectionMap,
|
||||||
validateGraph,
|
validateGraph,
|
||||||
|
|
@ -20,7 +20,7 @@ from modules.workflows.automation2.graphUtils import (
|
||||||
getLoopPrimaryInputSource,
|
getLoopPrimaryInputSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
from modules.workflows.automation2.executors import (
|
from modules.workflowAutomation.engine.executors import (
|
||||||
TriggerExecutor,
|
TriggerExecutor,
|
||||||
FlowExecutor,
|
FlowExecutor,
|
||||||
ActionNodeExecutor,
|
ActionNodeExecutor,
|
||||||
|
|
@ -29,15 +29,15 @@ from modules.workflows.automation2.executors import (
|
||||||
PauseForHumanTaskError,
|
PauseForHumanTaskError,
|
||||||
PauseForEmailWaitError,
|
PauseForEmailWaitError,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit
|
from modules.workflowAutomation.editor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
|
from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
|
||||||
from modules.workflows.automation2.graphicalEditorRunFileLogger import (
|
from modules.workflowAutomation.engine.runFileLogger import (
|
||||||
GraphicalEditorRunFileLogger,
|
RunFileLogger,
|
||||||
graphical_editor_run_file_logging_enabled,
|
graphical_editor_run_file_logging_enabled,
|
||||||
merge_run_context_with_ge_log_prefix,
|
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__)
|
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:
|
if not iface or not runId:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoStepLog
|
from modules.datamodels.datamodelWorkflowAutomation import AutoStepLog
|
||||||
stepId = str(uuid.uuid4())
|
stepId = str(uuid.uuid4())
|
||||||
startedAt = time.time()
|
startedAt = time.time()
|
||||||
iface.db.recordCreate(AutoStepLog, {
|
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:
|
if not iface or not stepId:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoStepLog
|
from modules.datamodels.datamodelWorkflowAutomation import AutoStepLog
|
||||||
completedAt = time.time()
|
completedAt = time.time()
|
||||||
updates: Dict[str, Any] = {
|
updates: Dict[str, Any] = {
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|
@ -333,7 +333,7 @@ def _ge_iso_timestamp() -> str:
|
||||||
|
|
||||||
|
|
||||||
async def _ge_log_node_finished(
|
async def _ge_log_node_finished(
|
||||||
file_logger: Optional[GraphicalEditorRunFileLogger],
|
file_logger: Optional[RunFileLogger],
|
||||||
*,
|
*,
|
||||||
run_id: Optional[str],
|
run_id: Optional[str],
|
||||||
node_outputs: Dict[str, Any],
|
node_outputs: Dict[str, Any],
|
||||||
|
|
@ -511,7 +511,7 @@ async def _run_post_loop_done_nodes(
|
||||||
automation2_interface: Optional[Any],
|
automation2_interface: Optional[Any],
|
||||||
runId: Optional[str],
|
runId: Optional[str],
|
||||||
processed_in_loop: Set[str],
|
processed_in_loop: Set[str],
|
||||||
ge_file_logger: Optional[GraphicalEditorRunFileLogger] = None,
|
ge_file_logger: Optional[RunFileLogger] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""After all loop iterations: merge upstream into loop output and run the Done (output 1) branch once."""
|
"""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)
|
_prim_in = getLoopPrimaryInputSource(loop_node_id, connectionMap, body_ids)
|
||||||
|
|
@ -705,13 +705,13 @@ async def executeGraph(
|
||||||
)
|
)
|
||||||
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
|
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
|
||||||
discoverMethods(services)
|
discoverMethods(services)
|
||||||
from modules.workflows.automation2.pickNotPushMigration import (
|
from modules.workflowAutomation.engine.pickNotPushMigration import (
|
||||||
materializeConnectionRefs,
|
materializeConnectionRefs,
|
||||||
materializePrimaryTextHandover,
|
materializePrimaryTextHandover,
|
||||||
materializeRecommendedDataPickRef,
|
materializeRecommendedDataPickRef,
|
||||||
normalizeFileCreatePresentationRefs,
|
normalizeFileCreatePresentationRefs,
|
||||||
)
|
)
|
||||||
from modules.workflows.automation2.featureInstanceRefMigration import (
|
from modules.workflowAutomation.engine.featureInstanceRefMigration import (
|
||||||
materializeFeatureInstanceRefs,
|
materializeFeatureInstanceRefs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -767,7 +767,7 @@ async def executeGraph(
|
||||||
except Exception as valErr:
|
except Exception as valErr:
|
||||||
logger.warning("executeGraph resume: schema validation failed for %s: %s", startAfterNodeId, 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 {})
|
nodeOutputs: Dict[str, Any] = dict(initialNodeOutputs or {})
|
||||||
if not runId and automation2_interface and workflowId and not is_resume:
|
if not runId and automation2_interface and workflowId and not is_resume:
|
||||||
run_context = {
|
run_context = {
|
||||||
|
|
@ -806,7 +806,7 @@ async def executeGraph(
|
||||||
runId = run.get("id") if run else None
|
runId = run.get("id") if run else None
|
||||||
logger.info("executeGraph created run %s label=%s", runId, run_label)
|
logger.info("executeGraph created run %s label=%s", runId, run_label)
|
||||||
if runId and graphical_editor_run_file_logging_enabled():
|
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,
|
automation2_interface,
|
||||||
runId,
|
runId,
|
||||||
run_context,
|
run_context,
|
||||||
|
|
@ -847,7 +847,7 @@ async def executeGraph(
|
||||||
and runId
|
and runId
|
||||||
and ge_file_logger is None
|
and ge_file_logger is None
|
||||||
):
|
):
|
||||||
ge_file_logger = GraphicalEditorRunFileLogger.ensure_attached(
|
ge_file_logger = RunFileLogger.ensure_attached(
|
||||||
automation2_interface,
|
automation2_interface,
|
||||||
runId,
|
runId,
|
||||||
)
|
)
|
||||||
|
|
@ -1542,7 +1542,7 @@ async def executeGraph(
|
||||||
logger.info("executeGraph paused for email wait (run %s, node %s)", e.runId, e.nodeId)
|
logger.info("executeGraph paused for email wait (run %s, node %s)", e.runId, e.nodeId)
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.features.graphicalEditor.emailPoller import ensureRunning
|
from modules.workflowAutomation.scheduler.emailPoller import ensureRunning
|
||||||
root = getRootInterface()
|
root = getRootInterface()
|
||||||
event_user = root.getUserByUsername("event") if root else None
|
event_user = root.getUserByUsername("event") if root else None
|
||||||
if event_user:
|
if event_user:
|
||||||
|
|
@ -1612,7 +1612,7 @@ async def executeGraph(
|
||||||
) if _wfObj else {}
|
) if _wfObj else {}
|
||||||
_shouldNotify = _wfDict.get("notifyOnFailure", True) if _wfDict else True
|
_shouldNotify = _wfDict.get("notifyOnFailure", True) if _wfDict else True
|
||||||
if _shouldNotify:
|
if _shouldNotify:
|
||||||
from modules.workflows.scheduler.mainScheduler import notifyRunFailed
|
from modules.workflowAutomation.scheduler.mainScheduler import notifyRunFailed
|
||||||
notifyRunFailed(
|
notifyRunFailed(
|
||||||
workflowId or "", runId or "", str(e),
|
workflowId or "", runId or "", str(e),
|
||||||
mandateId=mandateId,
|
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
|
import time
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from modules.features.graphicalEditor.portTypes import (
|
from modules.workflowAutomation.editor.portTypes import (
|
||||||
_normalizeError,
|
_normalizeError,
|
||||||
normalizeToSchema,
|
normalizeToSchema,
|
||||||
)
|
)
|
||||||
from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
|
from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
|
||||||
from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError
|
from modules.workflowAutomation.engine.executors.inputExecutor import PauseForHumanTaskError
|
||||||
from modules.workflows.methods.methodContext.actions.extractContent import (
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
PRESENTATION_KIND,
|
PRESENTATION_KIND,
|
||||||
build_presentation_envelope_from_plain_text,
|
build_presentation_envelope_from_plain_text,
|
||||||
|
|
@ -181,7 +181,7 @@ def _isUserConnectionId(val: Any) -> bool:
|
||||||
|
|
||||||
def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]:
|
def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get node definition by type id."""
|
"""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:
|
for node in STATIC_NODE_TYPES:
|
||||||
if node.get("id") == nodeType:
|
if node.get("id") == nodeType:
|
||||||
return node
|
return node
|
||||||
|
|
@ -304,7 +304,7 @@ def _buildConnectionRefDict(connRef: str, chatService, services) -> Optional[Dic
|
||||||
|
|
||||||
def _schemaCarriesConnectionProvenance(outputSchema: str) -> bool:
|
def _schemaCarriesConnectionProvenance(outputSchema: str) -> bool:
|
||||||
"""True iff the port schema declares ``carriesConnectionProvenance`` in the catalog."""
|
"""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)
|
schema = PORT_TYPE_CATALOG.get(outputSchema)
|
||||||
return bool(getattr(schema, "carriesConnectionProvenance", False))
|
return bool(getattr(schema, "carriesConnectionProvenance", False))
|
||||||
|
|
||||||
|
|
@ -388,7 +388,7 @@ def _mapper_emailDraftContextFromSubjectBody(params: Dict, **_) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _mapper_clickupTaskUpdateMerge(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)
|
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.
|
the first ``connectionMap`` entry so ``injectUpstreamPayload`` (e.g.
|
||||||
``context.mergeContext`` after ``flow.loop``) still receives data.
|
``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 {}
|
nodeOutputs = context.get("nodeOutputs") or {}
|
||||||
connectionMap = context.get("connectionMap") 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)
|
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."""
|
"""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 {}
|
src_map = (context.get("inputSources") or {}).get(nodeId) or {}
|
||||||
nodeOutputs = context.get("nodeOutputs") or {}
|
nodeOutputs = context.get("nodeOutputs") or {}
|
||||||
out: Dict[int, Any] = {}
|
out: Dict[int, Any] = {}
|
||||||
|
|
@ -484,8 +484,8 @@ class ActionNodeExecutor:
|
||||||
node: Dict[str, Any],
|
node: Dict[str, Any],
|
||||||
context: Dict[str, Any],
|
context: Dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from modules.features.graphicalEditor.nodeRegistry import getNodeTypeToMethodAction
|
from modules.workflowAutomation.editor.nodeRegistry import getNodeTypeToMethodAction
|
||||||
from modules.workflows.automation2.graphUtils import (
|
from modules.workflowAutomation.engine.graphUtils import (
|
||||||
document_list_param_is_empty,
|
document_list_param_is_empty,
|
||||||
extract_wired_document_list,
|
extract_wired_document_list,
|
||||||
resolveParameterReferences,
|
resolveParameterReferences,
|
||||||
|
|
@ -569,7 +569,7 @@ class ActionNodeExecutor:
|
||||||
workflowId = context.get("workflowId")
|
workflowId = context.get("workflowId")
|
||||||
connRef = resolvedParams.get("connectionReference")
|
connRef = resolvedParams.get("connectionReference")
|
||||||
if runId and workflowId and connRef:
|
if runId and workflowId and connRef:
|
||||||
from modules.workflows.automation2.executors import PauseForEmailWaitError
|
from modules.workflowAutomation.engine.executors import PauseForEmailWaitError
|
||||||
waitConfig = {
|
waitConfig = {
|
||||||
"connectionReference": connRef,
|
"connectionReference": connRef,
|
||||||
"folder": resolvedParams.get("folder", "Inbox"),
|
"folder": resolvedParams.get("folder", "Inbox"),
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -5,8 +5,8 @@ import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from modules.features.graphicalEditor.conditionOperators import apply_condition_operator, resolve_value_kind
|
from modules.workflowAutomation.editor.conditionOperators import apply_condition_operator, resolve_value_kind
|
||||||
from modules.features.graphicalEditor.portTypes import wrapTransit, unwrapTransit
|
from modules.workflowAutomation.editor.portTypes import wrapTransit, unwrapTransit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ class FlowExecutor:
|
||||||
return False
|
return False
|
||||||
if isinstance(condParam, dict) and condParam.get("type") == "condition":
|
if isinstance(condParam, dict) and condParam.get("type") == "condition":
|
||||||
return self._evalStructuredCondition(condParam, nodeOutputs, item_param=item_param, node=node)
|
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)
|
resolved = resolveParameterReferences(condParam, nodeOutputs)
|
||||||
return self._evalCondition(resolved)
|
return self._evalCondition(resolved)
|
||||||
|
|
||||||
|
|
@ -121,7 +121,7 @@ class FlowExecutor:
|
||||||
node: Optional[Dict] = None,
|
node: Optional[Dict] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Evaluate structured {operator, value} with Item dataRef (legacy: condition.ref)."""
|
"""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
|
left_ref = item_param
|
||||||
if left_ref is None or (isinstance(left_ref, dict) and not left_ref):
|
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:
|
async def _switch(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any:
|
||||||
params = node.get("parameters") or {}
|
params = node.get("parameters") or {}
|
||||||
valueExpr = params.get("value", "")
|
valueExpr = params.get("value", "")
|
||||||
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences
|
||||||
from modules.features.graphicalEditor.switchOutput import (
|
from modules.workflowAutomation.editor.switchOutput import (
|
||||||
build_switch_combined_output,
|
build_switch_combined_output,
|
||||||
build_switch_default_payload,
|
build_switch_default_payload,
|
||||||
)
|
)
|
||||||
|
|
@ -258,7 +258,7 @@ class FlowExecutor:
|
||||||
async def _loop(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any:
|
async def _loop(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any:
|
||||||
params = node.get("parameters") or {}
|
params = node.get("parameters") or {}
|
||||||
itemsPath = params.get("items", "[]")
|
itemsPath = params.get("items", "[]")
|
||||||
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences
|
||||||
|
|
||||||
raw = resolveParameterReferences(
|
raw = resolveParameterReferences(
|
||||||
itemsPath,
|
itemsPath,
|
||||||
|
|
@ -47,7 +47,7 @@ class InputExecutor:
|
||||||
)
|
)
|
||||||
taskId = task.get("id")
|
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(
|
_pause_ctx = merge_persisted_run_context(
|
||||||
self.automation2,
|
self.automation2,
|
||||||
|
|
@ -37,7 +37,7 @@ class IOExecutor:
|
||||||
nodeOutputs = context.get("nodeOutputs", {})
|
nodeOutputs = context.get("nodeOutputs", {})
|
||||||
params = dict(node.get("parameters") or {})
|
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)
|
resolvedParams = resolveParameterReferences(params, nodeOutputs)
|
||||||
logger.info("IOExecutor node %s resolvedParams keys=%s", nodeId, list(resolvedParams.keys()))
|
logger.info("IOExecutor node %s resolvedParams keys=%s", nodeId, list(resolvedParams.keys()))
|
||||||
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ def getLoopPrimaryInputSource(
|
||||||
) -> Optional[Tuple[str, int]]:
|
) -> Optional[Tuple[str, int]]:
|
||||||
"""Pick the inbound edge for ``flow.loop`` when several wires hit the same input (0).
|
"""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.
|
für die Datenzusammenführung (Fertig-Ausgang, Logs) zählt der Vorgänger **außerhalb** des Rumpfes.
|
||||||
"""
|
"""
|
||||||
incoming = connectionMap.get(loop_node_id, [])
|
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``).
|
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.
|
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)
|
sch = deriveFormPayloadSchemaFromParam(node, parameter_key)
|
||||||
if sch is None:
|
if sch is None:
|
||||||
|
|
@ -227,8 +227,8 @@ def _checkPortCompatibility(
|
||||||
"""
|
"""
|
||||||
Hard typed-port check: incompatible connections become validation errors.
|
Hard typed-port check: incompatible connections become validation errors.
|
||||||
"""
|
"""
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.portTypes import resolve_output_schema_name
|
from modules.workflowAutomation.editor.portTypes import resolve_output_schema_name
|
||||||
|
|
||||||
nodeDefMap = {n["id"]: n for n in STATIC_NODE_TYPES}
|
nodeDefMap = {n["id"]: n for n in STATIC_NODE_TYPES}
|
||||||
nodeById = {n["id"]: n for n in nodes if n.get("id")}
|
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:
|
if consumer_node_id and input_sources:
|
||||||
wired = (input_sources.get(consumer_node_id) or {}).get(0)
|
wired = (input_sources.get(consumer_node_id) or {}).get(0)
|
||||||
if wired and wired[0] == node_id:
|
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])
|
data = unwrap_transit_for_port(data, wired[1])
|
||||||
elif isinstance(data, dict) and data.get("_transit"):
|
elif isinstance(data, dict) and data.get("_transit"):
|
||||||
data = data.get("data", data)
|
data = data.get("data", data)
|
||||||
plist = list(path)
|
plist = list(path)
|
||||||
resolved = _get_by_path(data, plist)
|
resolved = _get_by_path(data, plist)
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
from modules.workflows.automation2.pickNotPushMigration import (
|
from modules.workflowAutomation.engine.pickNotPushMigration import (
|
||||||
remap_stale_presentation_ref_path,
|
remap_stale_presentation_ref_path,
|
||||||
)
|
)
|
||||||
alt_path = remap_stale_presentation_ref_path(plist)
|
alt_path = remap_stale_presentation_ref_path(plist)
|
||||||
|
|
@ -481,7 +481,7 @@ def resolveParameterReferences(
|
||||||
)
|
)
|
||||||
if value.get("type") == "system":
|
if value.get("type") == "system":
|
||||||
variable = value.get("variable", "")
|
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 resolveSystemVariable(variable, nodeOutputs.get("_context", {}))
|
||||||
return {
|
return {
|
||||||
k: resolveParameterReferences(
|
k: resolveParameterReferences(
|
||||||
|
|
@ -576,7 +576,7 @@ def extract_wired_document_list(inp: Any) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
if inp is None:
|
if inp is None:
|
||||||
return None
|
return None
|
||||||
from modules.features.graphicalEditor.portTypes import (
|
from modules.workflowAutomation.editor.portTypes import (
|
||||||
unwrapTransit,
|
unwrapTransit,
|
||||||
_coerce_document_list_upload_fields,
|
_coerce_document_list_upload_fields,
|
||||||
_file_record_to_document,
|
_file_record_to_document,
|
||||||
|
|
@ -16,12 +16,12 @@ import copy
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.portTypes import (
|
from modules.workflowAutomation.editor.portTypes import (
|
||||||
PRIMARY_TEXT_HANDOVER_REF_PATH,
|
PRIMARY_TEXT_HANDOVER_REF_PATH,
|
||||||
resolve_output_schema_name,
|
resolve_output_schema_name,
|
||||||
)
|
)
|
||||||
from modules.workflows.automation2.graphUtils import buildConnectionMap, getInputSources
|
from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getInputSources
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ def merge_persisted_run_context(
|
||||||
return {**prev, **(replacement or {})}
|
return {**prev, **(replacement or {})}
|
||||||
|
|
||||||
|
|
||||||
class GraphicalEditorRunFileLogger:
|
class RunFileLogger:
|
||||||
"""Append-only NDJSON log for one run folder under ``resolve_app_log_dir()``."""
|
"""Append-only NDJSON log for one run folder under ``resolve_app_log_dir()``."""
|
||||||
|
|
||||||
__slots__ = ("_exec_path", "_ctx_path", "_lock", "_run_id")
|
__slots__ = ("_exec_path", "_ctx_path", "_lock", "_run_id")
|
||||||
|
|
@ -80,7 +80,7 @@ class GraphicalEditorRunFileLogger:
|
||||||
return "/".join((RUN_FILE_LOG_RELATIVE_ROOT, subdir_name))
|
return "/".join((RUN_FILE_LOG_RELATIVE_ROOT, subdir_name))
|
||||||
|
|
||||||
@classmethod
|
@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``."""
|
"""Create filesystem folder + persist CONTEXT_KEY via ``updateRun``."""
|
||||||
if not graphical_editor_run_file_logging_enabled():
|
if not graphical_editor_run_file_logging_enabled():
|
||||||
return None
|
return None
|
||||||
|
|
@ -107,7 +107,7 @@ class GraphicalEditorRunFileLogger:
|
||||||
return cls(run_id, absolute)
|
return cls(run_id, absolute)
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""Open logger for an existing run using CONTEXT_KEY from DB."""
|
||||||
if not graphical_editor_run_file_logging_enabled():
|
if not graphical_editor_run_file_logging_enabled():
|
||||||
return None
|
return None
|
||||||
|
|
@ -154,7 +154,7 @@ class GraphicalEditorRunFileLogger:
|
||||||
return cand if os.path.isdir(cand) else None
|
return cand if os.path.isdir(cand) else None
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""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)
|
opened = cls.open_from_run_record(automation2_interface, run_id)
|
||||||
if opened is not None:
|
if opened is not None:
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
GraphicalEditor Feature - n8n-style flow automation.
|
WorkflowAutomation System Component — n8n-style flow automation.
|
||||||
Minimal bootstrap for feature instance creation. Build from here.
|
|
||||||
|
System-level orchestration infrastructure (not a feature).
|
||||||
|
Provides lifecycle hooks, service hub, and system templates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -14,7 +16,7 @@ from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
FEATURE_CODE = "graphicalEditor"
|
COMPONENT_CODE = "workflowAutomation"
|
||||||
|
|
||||||
REQUIRED_SERVICES = [
|
REQUIRED_SERVICES = [
|
||||||
{"serviceKey": "chat", "meta": {"usage": "Interfaces, RBAC"}},
|
{"serviceKey": "chat", "meta": {"usage": "Interfaces, RBAC"}},
|
||||||
|
|
@ -25,41 +27,21 @@ REQUIRED_SERVICES = [
|
||||||
{"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}},
|
{"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}},
|
||||||
{"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}},
|
{"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]:
|
||||||
def getRequiredServiceKeys() -> List[str]:
|
"""Return list of service keys this component requires."""
|
||||||
"""Return list of service keys this feature requires."""
|
|
||||||
return [s["serviceKey"] for s in REQUIRED_SERVICES]
|
return [s["serviceKey"] for s in REQUIRED_SERVICES]
|
||||||
|
|
||||||
|
|
||||||
def getGraphicalEditorServices(
|
def _getWorkflowAutomationServices(
|
||||||
user,
|
user,
|
||||||
mandateId: Optional[str] = None,
|
mandateId: Optional[str] = None,
|
||||||
featureInstanceId: Optional[str] = None,
|
featureInstanceId: Optional[str] = None,
|
||||||
workflow=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).
|
Used for methodDiscovery (I/O nodes) and execution (ActionExecutor).
|
||||||
"""
|
"""
|
||||||
from modules.serviceCenter import getService
|
from modules.serviceCenter import getService
|
||||||
|
|
@ -70,7 +52,7 @@ def getGraphicalEditorServices(
|
||||||
_workflow = type(
|
_workflow = type(
|
||||||
"_Placeholder",
|
"_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(
|
ctx = ServiceCenterContext(
|
||||||
|
|
@ -80,13 +62,13 @@ def getGraphicalEditorServices(
|
||||||
workflow=_workflow,
|
workflow=_workflow,
|
||||||
)
|
)
|
||||||
|
|
||||||
hub = _GraphicalEditorServiceHub()
|
hub = _WorkflowAutomationServiceHub()
|
||||||
hub.user = user
|
hub.user = user
|
||||||
hub.mandateId = mandateId
|
hub.mandateId = mandateId
|
||||||
hub.featureInstanceId = featureInstanceId
|
hub.featureInstanceId = featureInstanceId
|
||||||
hub._service_context = ctx
|
hub._service_context = ctx
|
||||||
hub.workflow = _workflow
|
hub.workflow = _workflow
|
||||||
hub.featureCode = FEATURE_CODE
|
hub.featureCode = COMPONENT_CODE
|
||||||
|
|
||||||
for spec in REQUIRED_SERVICES:
|
for spec in REQUIRED_SERVICES:
|
||||||
key = spec["serviceKey"]
|
key = spec["serviceKey"]
|
||||||
|
|
@ -94,7 +76,7 @@ def getGraphicalEditorServices(
|
||||||
svc = getService(key, ctx)
|
svc = getService(key, ctx)
|
||||||
setattr(hub, key, svc)
|
setattr(hub, key, svc)
|
||||||
except Exception as e:
|
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)
|
setattr(hub, key, None)
|
||||||
|
|
||||||
if hub.chat:
|
if hub.chat:
|
||||||
|
|
@ -106,19 +88,17 @@ def getGraphicalEditorServices(
|
||||||
return hub
|
return hub
|
||||||
|
|
||||||
|
|
||||||
# Backward-compatible alias used by workflows/automation2/ execution engine
|
|
||||||
getAutomation2Services = getGraphicalEditorServices
|
|
||||||
|
|
||||||
|
|
||||||
class _GraphicalEditorServiceHub:
|
class _WorkflowAutomationServiceHub:
|
||||||
"""Lightweight hub for graphicalEditor (methodDiscovery, execution)."""
|
"""Lightweight hub for WorkflowAutomation (methodDiscovery, execution)."""
|
||||||
|
|
||||||
user = None
|
user = None
|
||||||
mandateId = None
|
mandateId = None
|
||||||
featureInstanceId = None
|
featureInstanceId = None
|
||||||
_service_context = None
|
_service_context = None
|
||||||
workflow = None
|
workflow = None
|
||||||
featureCode = FEATURE_CODE
|
featureCode = COMPONENT_CODE
|
||||||
interfaceDbApp = None
|
interfaceDbApp = None
|
||||||
interfaceDbComponent = None
|
interfaceDbComponent = None
|
||||||
interfaceDbChat = None
|
interfaceDbChat = None
|
||||||
|
|
@ -132,14 +112,12 @@ class _GraphicalEditorServiceHub:
|
||||||
generation = None
|
generation = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Feature Lifecycle Hooks (called dynamically by core via loadFeatureMainModules)
|
# Lifecycle Hooks
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def onMandateDelete(mandateId: str, instances: list) -> None:
|
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 (
|
from modules.datamodels.datamodelWorkflowAutomation import (
|
||||||
GRAPHICAL_EDITOR_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
|
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
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
try:
|
try:
|
||||||
geDb = DatabaseConnector(
|
waDb = DatabaseConnector(
|
||||||
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
dbDatabase=GRAPHICAL_EDITOR_DATABASE,
|
dbDatabase=GRAPHICAL_EDITOR_DATABASE,
|
||||||
dbUser=APP_CONFIG.get("DB_USER"),
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
|
|
@ -156,69 +134,116 @@ def onMandateDelete(mandateId: str, instances: list) -> None:
|
||||||
userId=None,
|
userId=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not geDb._ensureTableExists(AutoWorkflow):
|
if not waDb._ensureTableExists(AutoWorkflow):
|
||||||
return
|
return
|
||||||
|
|
||||||
geInstances = [
|
workflows = waDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
inst for inst in instances
|
"mandateId": mandateId,
|
||||||
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == "graphicalEditor"
|
}) or []
|
||||||
]
|
|
||||||
|
|
||||||
totalDeleted = 0
|
totalDeleted = 0
|
||||||
for inst in geInstances:
|
for wf in workflows:
|
||||||
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
|
wfId = wf.get("id")
|
||||||
if not instId:
|
if not wfId:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
|
for v in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
||||||
"mandateId": mandateId,
|
waDb.recordDelete(AutoVersion, v.get("id"))
|
||||||
"featureInstanceId": instId,
|
|
||||||
}) or []
|
|
||||||
|
|
||||||
for wf in workflows:
|
for run in waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
|
||||||
wfId = wf.get("id")
|
runId = run.get("id")
|
||||||
if not wfId:
|
for sl in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
||||||
continue
|
waDb.recordDelete(AutoStepLog, sl.get("id"))
|
||||||
|
waDb.recordDelete(AutoRun, runId)
|
||||||
|
|
||||||
for v in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
||||||
geDb.recordDelete(AutoVersion, v.get("id"))
|
waDb.recordDelete(AutoTask, task.get("id"))
|
||||||
|
|
||||||
for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
|
waDb.recordDelete(AutoWorkflow, wfId)
|
||||||
runId = run.get("id")
|
totalDeleted += 1
|
||||||
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
|
|
||||||
|
|
||||||
if totalDeleted:
|
if totalDeleted:
|
||||||
logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) in Greenfield DB for mandate {mandateId}")
|
logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) for mandate {mandateId}")
|
||||||
geDb.close()
|
waDb.close()
|
||||||
except Exception as e:
|
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:
|
def onBootstrap() -> None:
|
||||||
"""Seed system workflow templates and sync feature template workflows on boot."""
|
"""Seed system workflow templates and sync feature template workflows on boot."""
|
||||||
|
_migrateRbacNamespace()
|
||||||
|
|
||||||
from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow
|
from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
try:
|
try:
|
||||||
greenfieldDb = DatabaseConnector(
|
waDb = DatabaseConnector(
|
||||||
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
dbDatabase=GRAPHICAL_EDITOR_DATABASE,
|
dbDatabase=GRAPHICAL_EDITOR_DATABASE,
|
||||||
dbUser=APP_CONFIG.get("DB_USER"),
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
)
|
)
|
||||||
greenfieldDb._ensureTableExists(AutoWorkflow)
|
waDb._ensureTableExists(AutoWorkflow)
|
||||||
|
|
||||||
# --- Seed system templates ---
|
existing = waDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
existing = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={
|
|
||||||
"isTemplate": True,
|
"isTemplate": True,
|
||||||
"templateScope": "system",
|
"templateScope": "system",
|
||||||
})
|
})
|
||||||
|
|
@ -230,13 +255,12 @@ def onBootstrap() -> None:
|
||||||
if tpl["label"] in existingLabels:
|
if tpl["label"] in existingLabels:
|
||||||
continue
|
continue
|
||||||
tpl["id"] = str(uuid.uuid4())
|
tpl["id"] = str(uuid.uuid4())
|
||||||
greenfieldDb.recordCreate(AutoWorkflow, tpl)
|
waDb.recordCreate(AutoWorkflow, tpl)
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
logger.info(f"Bootstrapped {created} system workflow template(s)")
|
logger.info(f"Bootstrapped {created} system workflow template(s)")
|
||||||
|
|
||||||
# --- Sync feature template workflows ---
|
|
||||||
from modules.system.registry import loadFeatureMainModules
|
from modules.system.registry import loadFeatureMainModules
|
||||||
|
|
||||||
mainModules = loadFeatureMainModules()
|
mainModules = loadFeatureMainModules()
|
||||||
|
|
@ -257,7 +281,7 @@ def onBootstrap() -> None:
|
||||||
if templatesBySourceId:
|
if templatesBySourceId:
|
||||||
updated = 0
|
updated = 0
|
||||||
for sourceId, tpl in templatesBySourceId.items():
|
for sourceId, tpl in templatesBySourceId.items():
|
||||||
instances = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={
|
instances = waDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
"templateSourceId": sourceId,
|
"templateSourceId": sourceId,
|
||||||
"isTemplate": False,
|
"isTemplate": False,
|
||||||
})
|
})
|
||||||
|
|
@ -285,25 +309,25 @@ def onBootstrap() -> None:
|
||||||
|
|
||||||
if existingGraph == newGraph:
|
if existingGraph == newGraph:
|
||||||
continue
|
continue
|
||||||
greenfieldDb.recordModify(AutoWorkflow, instId, {"graph": newGraph})
|
waDb.recordModify(AutoWorkflow, instId, {"graph": newGraph})
|
||||||
updated += 1
|
updated += 1
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
logger.info(f"Synced {updated} workflow(s) with current feature templates")
|
logger.info(f"Synced {updated} workflow(s) with current feature templates")
|
||||||
|
|
||||||
greenfieldDb.close()
|
waDb.close()
|
||||||
except Exception as e:
|
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:
|
def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, templateWorkflows: list) -> int:
|
||||||
"""Create workflow instances from template definitions when a feature instance is created."""
|
"""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.security.rootAccess import getRootUser
|
||||||
from modules.shared.i18nRegistry import resolveText
|
from modules.shared.i18nRegistry import resolveText
|
||||||
|
|
||||||
rootUser = getRootUser()
|
rootUser = getRootUser()
|
||||||
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
|
waInterface = _getWorkflowAutomationInterface(rootUser, mandateId, instanceId)
|
||||||
|
|
||||||
copied = 0
|
copied = 0
|
||||||
for template in templateWorkflows:
|
for template in templateWorkflows:
|
||||||
|
|
@ -315,7 +339,7 @@ def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, template
|
||||||
|
|
||||||
label = resolveText(template.get("label"))
|
label = resolveText(template.get("label"))
|
||||||
|
|
||||||
geInterface.createWorkflow({
|
waInterface.createWorkflow({
|
||||||
"label": label,
|
"label": label,
|
||||||
"graph": graph,
|
"graph": graph,
|
||||||
"tags": template.get("tags", [f"feature:{featureCode}"]),
|
"tags": template.get("tags", [f"feature:{featureCode}"]),
|
||||||
|
|
@ -395,8 +419,3 @@ def _buildSystemTemplates():
|
||||||
"invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}],
|
"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.
|
Stops the poller when no runs are waiting.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface as getAutomation2Interface
|
from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
|
||||||
from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices as getAutomation2Services
|
from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices
|
||||||
from modules.workflows.automation2.executionEngine import executeGraph
|
from modules.workflowAutomation.engine.executionEngine import executeGraph
|
||||||
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
|
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ async def _pollEmailWaits(eventUser) -> None:
|
||||||
logger.warning("Email poller: root interface not available")
|
logger.warning("Email poller: root interface not available")
|
||||||
return
|
return
|
||||||
# Use eventUser - getRunsWaitingForEmail queries by status only
|
# Use eventUser - getRunsWaitingForEmail queries by status only
|
||||||
a2 = getAutomation2Interface(eventUser, mandateId="", featureInstanceId="")
|
a2 = _getWorkflowAutomationInterface(eventUser, mandateId="", featureInstanceId="")
|
||||||
runs = a2.getRunsWaitingForEmail()
|
runs = a2.getRunsWaitingForEmail()
|
||||||
if not runs:
|
if not runs:
|
||||||
# No workflows waiting for email - stop the poller
|
# No workflows waiting for email - stop the poller
|
||||||
|
|
@ -77,7 +77,7 @@ async def _pollEmailWaits(eventUser) -> None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get workflow (need scoped interface for mandate/instance)
|
# 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)
|
wf = a2_scoped.getWorkflow(workflow_id)
|
||||||
if not wf or not wf.get("graph"):
|
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)
|
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)
|
logger.warning("Email wait run %s: paused at email.searchEmail (should not wait) – skipping", run_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
services = getAutomation2Services(owner, mandateId=mandate_id, featureInstanceId=instance_id)
|
services = _getWorkflowAutomationServices(owner, mandateId=mandate_id, featureInstanceId=instance_id)
|
||||||
discoverMethods(services)
|
discoverMethods(services)
|
||||||
|
|
||||||
# Build filter with receivedDateTime – only emails received at or after baseline (new emails)
|
# Build filter with receivedDateTime – only emails received at or after baseline (new emails)
|
||||||
|
|
@ -22,8 +22,8 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_main_loop = None
|
_main_loop = None
|
||||||
|
|
||||||
JOB_ID_PREFIX = "graphicalEditor."
|
JOB_ID_PREFIX = "workflowAutomation."
|
||||||
_CALLBACK_NAME = "graphicalEditor.workflow.changed"
|
_CALLBACK_NAME = "workflowAutomation.workflow.changed"
|
||||||
|
|
||||||
|
|
||||||
def _setMainLoop(loop) -> None:
|
def _setMainLoop(loop) -> None:
|
||||||
|
|
@ -76,8 +76,8 @@ class WorkflowScheduler:
|
||||||
Incremental sync: only re-register jobs whose eventId has changed.
|
Incremental sync: only re-register jobs whose eventId has changed.
|
||||||
Uses AutoWorkflow.eventId for change detection (v1 pattern).
|
Uses AutoWorkflow.eventId for change detection (v1 pattern).
|
||||||
"""
|
"""
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getAllWorkflowsForScheduling
|
from modules.interfaces.interfaceWorkflowAutomation import getAllWorkflowsForScheduling
|
||||||
from modules.workflows.automation2.scheduleCron import parse_cron_to_kwargs
|
from modules.workflowAutomation.engine.scheduleCron import parse_cron_to_kwargs
|
||||||
|
|
||||||
items = getAllWorkflowsForScheduling()
|
items = getAllWorkflowsForScheduling()
|
||||||
logger.info("WorkflowScheduler: found %d workflow(s) with trigger.schedule+cron", len(items))
|
logger.info("WorkflowScheduler: found %d workflow(s) with trigger.schedule+cron", len(items))
|
||||||
|
|
@ -174,7 +174,7 @@ class WorkflowScheduler:
|
||||||
currentEventId = workflow.get("eventId")
|
currentEventId = workflow.get("eventId")
|
||||||
if currentEventId != jobId:
|
if currentEventId != jobId:
|
||||||
try:
|
try:
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
root = getRootInterface()
|
root = getRootInterface()
|
||||||
eventUser = root.getUserByUsername("event") if root else self._eventUser
|
eventUser = root.getUserByUsername("event") if root else self._eventUser
|
||||||
|
|
@ -182,7 +182,7 @@ class WorkflowScheduler:
|
||||||
return
|
return
|
||||||
mandateId = workflow.get("mandateId", "")
|
mandateId = workflow.get("mandateId", "")
|
||||||
instanceId = workflow.get("featureInstanceId", "")
|
instanceId = workflow.get("featureInstanceId", "")
|
||||||
iface = getGraphicalEditorInterface(eventUser, mandateId, instanceId)
|
iface = _getWorkflowAutomationInterface(eventUser, mandateId, instanceId)
|
||||||
iface.updateWorkflow(workflowId, {"eventId": jobId})
|
iface.updateWorkflow(workflowId, {"eventId": jobId})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("WorkflowScheduler: could not update eventId for %s: %s", workflowId, 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")
|
logger.error("WorkflowScheduler: event user not available")
|
||||||
return
|
return
|
||||||
|
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
|
||||||
from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices
|
from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices
|
||||||
from modules.workflows.automation2.executionEngine import executeGraph
|
from modules.workflowAutomation.engine.executionEngine import executeGraph
|
||||||
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
|
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
|
||||||
from modules.features.graphicalEditor.entryPoints import find_invocation
|
from modules.workflowAutomation.editor.entryPoints import find_invocation
|
||||||
from modules.workflows.automation2.runEnvelope import default_run_envelope, normalize_run_envelope
|
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)
|
wf = iface.getWorkflow(workflowId)
|
||||||
if not wf or not wf.get("graph"):
|
if not wf or not wf.get("graph"):
|
||||||
logger.warning("WorkflowScheduler: workflow %s not found or no graph", workflowId)
|
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)
|
logger.info("WorkflowScheduler: entry point %s disabled for workflow %s", entryPointId, workflowId)
|
||||||
return
|
return
|
||||||
|
|
||||||
services = getGraphicalEditorServices(
|
services = _getWorkflowAutomationServices(
|
||||||
eventUser,
|
eventUser,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
featureInstanceId=instanceId,
|
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:
|
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."""
|
"""Notify on workflow run failure: emit event, create in-app notification, trigger email subscription."""
|
||||||
try:
|
try:
|
||||||
eventManager.emit("graphicalEditor.run.failed", {
|
eventManager.emit("workflowAutomation.run.failed", {
|
||||||
"workflowId": workflowId,
|
"workflowId": workflowId,
|
||||||
"runId": runId,
|
"runId": runId,
|
||||||
"error": error,
|
"error": error,
|
||||||
|
|
@ -362,12 +362,12 @@ def _createRunFailedNotification(
|
||||||
if not rootInterface:
|
if not rootInterface:
|
||||||
return
|
return
|
||||||
|
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
|
||||||
eventUser = rootInterface.getUserByUsername("event")
|
eventUser = rootInterface.getUserByUsername("event")
|
||||||
if not eventUser:
|
if not eventUser:
|
||||||
return
|
return
|
||||||
|
|
||||||
iface = getGraphicalEditorInterface(eventUser, mandateId or "", "")
|
iface = _getWorkflowAutomationInterface(eventUser, mandateId or "", "")
|
||||||
wf = iface.getWorkflow(workflowId)
|
wf = iface.getWorkflow(workflowId)
|
||||||
if not wf:
|
if not wf:
|
||||||
return
|
return
|
||||||
|
|
@ -1,2 +1,13 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# 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
|
# 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 modules.workflowAutomation.engine.executors import ( # noqa: F401
|
||||||
from .flowExecutor import FlowExecutor
|
TriggerExecutor,
|
||||||
from .actionNodeExecutor import ActionNodeExecutor
|
FlowExecutor,
|
||||||
from .inputExecutor import InputExecutor, PauseForHumanTaskError, PauseForEmailWaitError
|
ActionNodeExecutor,
|
||||||
from .dataExecutor import DataExecutor
|
InputExecutor,
|
||||||
|
DataExecutor,
|
||||||
|
PauseForHumanTaskError,
|
||||||
|
PauseForEmailWaitError,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"TriggerExecutor",
|
"TriggerExecutor",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ from modules.datamodels.datamodelWorkflowActions import (
|
||||||
WorkflowActionDefinition,
|
WorkflowActionDefinition,
|
||||||
WorkflowActionParameter,
|
WorkflowActionParameter,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.portTypes import (
|
from modules.workflowAutomation.editor.portTypes import (
|
||||||
PORT_TYPE_CATALOG,
|
PORT_TYPE_CATALOG,
|
||||||
PRIMITIVE_TYPES,
|
PRIMITIVE_TYPES,
|
||||||
_stripContainer,
|
_stripContainer,
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ class MethodBase:
|
||||||
runtime structural validation is handled by the workflow engine /
|
runtime structural validation is handled by the workflow engine /
|
||||||
port-schema layer, not at the action-call boundary.
|
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:
|
if expectedType in PORT_TYPE_CATALOG:
|
||||||
return value
|
return value
|
||||||
|
|
|
||||||
|
|
@ -320,7 +320,7 @@ def _pause_for_human_tasks(
|
||||||
)
|
)
|
||||||
task_id = str((task or {}).get("id") or "")
|
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")]
|
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(
|
_pause_ctx = merge_persisted_run_context(
|
||||||
iface,
|
iface,
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ def _isRefSchema(typeStr: str) -> bool:
|
||||||
"""
|
"""
|
||||||
if not typeStr or not typeStr.endswith("Ref"):
|
if not typeStr or not typeStr.endswith("Ref"):
|
||||||
return False
|
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)
|
schema = PORT_TYPE_CATALOG.get(typeStr)
|
||||||
if schema is None:
|
if schema is None:
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# 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})
|
memberships = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mid})
|
||||||
assert len(memberships) >= 1, f"User not member of mandate {mandate.get('label')}"
|
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):
|
def test_happylifeFeaturesExist(self, db, mandateHappylife, featureCode):
|
||||||
mid = mandateHappylife.get("id")
|
mid = mandateHappylife.get("id")
|
||||||
instances = _getFeatureInstances(db, mid, featureCode)
|
instances = _getFeatureInstances(db, mid, featureCode)
|
||||||
assert len(instances) >= 1, f"Feature '{featureCode}' missing in HappyLife AG"
|
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):
|
def test_alpinaFeaturesExist(self, db, mandateAlpina, featureCode):
|
||||||
mid = mandateAlpina.get("id")
|
mid = mandateAlpina.get("id")
|
||||||
instances = _getFeatureInstances(db, mid, featureCode)
|
instances = _getFeatureInstances(db, mid, featureCode)
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ class TestSystemWorkflowTemplates:
|
||||||
|
|
||||||
def test_systemTemplatesExist(self, db):
|
def test_systemTemplatesExist(self, db):
|
||||||
"""System workflow templates should exist (created by system bootstrap, not demo config)."""
|
"""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:
|
try:
|
||||||
templates = db.getRecordset(AutoWorkflow, recordFilter={"isTemplate": True, "templateScope": "system"})
|
templates = db.getRecordset(AutoWorkflow, recordFilter={"isTemplate": True, "templateScope": "system"})
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ class TestPwgDemoBootstrap:
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"featureCode",
|
"featureCode",
|
||||||
["workspace", "trustee", "graphicalEditor", "neutralization"],
|
["workspace", "trustee", "neutralization"],
|
||||||
)
|
)
|
||||||
def test_pwgFeaturesExist(self, db, mandatePwg, featureCode):
|
def test_pwgFeaturesExist(self, db, mandatePwg, featureCode):
|
||||||
instances = _getFeatureInstances(db, mandatePwg.get("id"), featureCode)
|
instances = _getFeatureInstances(db, mandatePwg.get("id"), featureCode)
|
||||||
|
|
@ -116,8 +116,8 @@ class TestPwgDemoBootstrap:
|
||||||
"mandateId": mandatePwg.get("id"),
|
"mandateId": mandatePwg.get("id"),
|
||||||
}) or []
|
}) or []
|
||||||
codes = sorted({i.get("featureCode") for i in instances})
|
codes = sorted({i.get("featureCode") for i in instances})
|
||||||
assert codes == ["graphicalEditor", "neutralization", "trustee", "workspace"], (
|
assert codes == ["neutralization", "trustee", "workspace"], (
|
||||||
f"Expected exactly 4 feature instances, got {codes}"
|
f"Expected exactly 3 feature instances, got {codes}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -183,20 +183,15 @@ class TestPwgTrusteeSeed:
|
||||||
class TestPwgPilotWorkflow:
|
class TestPwgPilotWorkflow:
|
||||||
|
|
||||||
def test_pilotWorkflowImported(self, db, mandatePwg):
|
def test_pilotWorkflowImported(self, db, mandatePwg):
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow
|
||||||
from modules.demoConfigs.pwgDemo2026 import _openGraphicalEditorDb
|
from modules.demoConfigs.pwgDemo2026 import _openWorkflowAutomationDb
|
||||||
instances = _getFeatureInstances(db, mandatePwg.get("id"), "graphicalEditor")
|
geDb = _openWorkflowAutomationDb()
|
||||||
assert instances, "No graphicalEditor instance for PWG"
|
|
||||||
instId = instances[0].get("id")
|
|
||||||
geDb = _openGraphicalEditorDb()
|
|
||||||
wfs = geDb.getRecordset(AutoWorkflow, recordFilter={
|
wfs = geDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
"mandateId": mandatePwg.get("id"),
|
"mandateId": mandatePwg.get("id"),
|
||||||
"featureInstanceId": instId,
|
|
||||||
"label": "PWG Pilot: Jahresmietzinsbestätigung",
|
"label": "PWG Pilot: Jahresmietzinsbestätigung",
|
||||||
}) or []
|
}) or []
|
||||||
assert len(wfs) == 1, f"Expected exactly 1 PWG pilot workflow, got {len(wfs)}"
|
assert len(wfs) == 1, f"Expected exactly 1 PWG pilot workflow, got {len(wfs)}"
|
||||||
wf = wfs[0]
|
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"
|
assert wf.get("active") is False, "PWG pilot workflow must be imported with active=false"
|
||||||
graph = wf.get("graph") or {}
|
graph = wf.get("graph") or {}
|
||||||
assert (graph.get("nodes") or []), "PWG pilot workflow has no nodes"
|
assert (graph.get("nodes") or []), "PWG pilot workflow has no nodes"
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,11 @@ from typing import Any, Dict
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from modules.workflows.automation2.featureInstanceRefMigration import (
|
from modules.workflowAutomation.engine.featureInstanceRefMigration import (
|
||||||
materializeFeatureInstanceRefs,
|
materializeFeatureInstanceRefs,
|
||||||
)
|
)
|
||||||
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences
|
||||||
from modules.workflows.automation2.pickNotPushMigration import materializeConnectionRefs
|
from modules.workflowAutomation.engine.pickNotPushMigration import materializeConnectionRefs
|
||||||
|
|
||||||
|
|
||||||
_TRUSTEE_INSTANCE_UUID = "f1e2d3c4-b5a6-7890-1234-567890abcdef"
|
_TRUSTEE_INSTANCE_UUID = "f1e2d3c4-b5a6-7890-1234-567890abcdef"
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,8 @@ from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from modules.workflows.automation2.executionEngine import executeGraph
|
from modules.workflowAutomation.engine.executionEngine import executeGraph
|
||||||
from modules.workflows.automation2.runEnvelope import default_run_envelope
|
from modules.workflowAutomation.engine.runEnvelope import default_run_envelope
|
||||||
|
|
||||||
|
|
||||||
_TRUSTEE_INSTANCE_UUID = "11111111-2222-3333-4444-555555555555"
|
_TRUSTEE_INSTANCE_UUID = "11111111-2222-3333-4444-555555555555"
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from modules.workflows.automation2.executionEngine import executeGraph
|
from modules.workflowAutomation.engine.executionEngine import executeGraph
|
||||||
from modules.workflows.automation2.graphUtils import buildConnectionMap, getInputSources
|
from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getInputSources
|
||||||
from modules.workflows.automation2.executors.dataExecutor import DataExecutor
|
from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor
|
||||||
from modules.workflows.automation2.runEnvelope import default_run_envelope
|
from modules.workflowAutomation.engine.runEnvelope import default_run_envelope
|
||||||
|
|
||||||
|
|
||||||
def _minimal_services():
|
def _minimal_services():
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# 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():
|
def test_build_connection_ref_dict_from_logical_string():
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,14 @@ from modules.datamodels.datamodelWorkflowActions import (
|
||||||
WorkflowActionDefinition,
|
WorkflowActionDefinition,
|
||||||
WorkflowActionParameter,
|
WorkflowActionParameter,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.adapterValidator import (
|
from modules.workflowAutomation.editor.adapterValidator import (
|
||||||
AdapterValidationReport,
|
AdapterValidationReport,
|
||||||
_buildActionsRegistryFromMethods,
|
_buildActionsRegistryFromMethods,
|
||||||
_formatAdapterReport,
|
_formatAdapterReport,
|
||||||
_validateAdapterAgainstAction,
|
_validateAdapterAgainstAction,
|
||||||
_validateAllAdapters,
|
_validateAllAdapters,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.nodeAdapter import (
|
from modules.workflowAutomation.editor.nodeAdapter import (
|
||||||
NodeAdapter,
|
NodeAdapter,
|
||||||
UserParamMapping,
|
UserParamMapping,
|
||||||
)
|
)
|
||||||
|
|
@ -334,7 +334,7 @@ def test_staticNodesHaveNoDriftAgainstLiveMethods():
|
||||||
|
|
||||||
History: wiki/c-work/4-done/2026-04-adapter-drift-cleanup.md
|
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()
|
instances = _instantiateLiveMethods()
|
||||||
if not instances:
|
if not instances:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
"""Tests for backend-driven condition operator catalog."""
|
"""Tests for backend-driven condition operator catalog."""
|
||||||
|
|
||||||
from modules.features.graphicalEditor.conditionOperators import (
|
from modules.workflowAutomation.editor.conditionOperators import (
|
||||||
CONDITION_OPERATOR_CATALOG,
|
CONDITION_OPERATOR_CATALOG,
|
||||||
VALUE_KINDS,
|
VALUE_KINDS,
|
||||||
apply_condition_operator,
|
apply_condition_operator,
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.redmine import REDMINE_NODES
|
from modules.workflowAutomation.editor.nodeDefinitions.redmine import REDMINE_NODES
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.trustee import TRUSTEE_NODES
|
from modules.workflowAutomation.editor.nodeDefinitions.trustee import TRUSTEE_NODES
|
||||||
|
|
||||||
|
|
||||||
def _featureInstanceParam(node: dict) -> dict | None:
|
def _featureInstanceParam(node: dict) -> dict | None:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeAdapter import (
|
from modules.workflowAutomation.editor.nodeAdapter import (
|
||||||
NodeAdapter,
|
NodeAdapter,
|
||||||
UserParamMapping,
|
UserParamMapping,
|
||||||
_adapterFromLegacyNode,
|
_adapterFromLegacyNode,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Catalog integrity + new Phase-1 schemas
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from modules.features.graphicalEditor.portTypes import (
|
from modules.workflowAutomation.editor.portTypes import (
|
||||||
PORT_TYPE_CATALOG,
|
PORT_TYPE_CATALOG,
|
||||||
PRIMITIVE_TYPES,
|
PRIMITIVE_TYPES,
|
||||||
PortField,
|
PortField,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
"""Port type catalog: nested provenance schemas (Typed Generic Handover)."""
|
"""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():
|
def test_connection_ref_in_catalog():
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
"""Tests for condition valueKind resolution."""
|
"""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):
|
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
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths
|
from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths
|
||||||
from modules.workflows.automation2.graphUtils import parse_graph_defined_schema, validateGraph
|
from modules.workflowAutomation.engine.graphUtils import parse_graph_defined_schema, validateGraph
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
|
|
||||||
|
|
||||||
def test_compute_upstream_paths_includes_form_dynamic_fields():
|
def test_compute_upstream_paths_includes_form_dynamic_fields():
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@ Verifies that:
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG
|
from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG
|
||||||
from modules.workflows.automation2.executors import actionNodeExecutor as _actionExec
|
from modules.workflowAutomation.engine.executors import actionNodeExecutor as _actionExec
|
||||||
from modules.workflows.automation2.graphUtils import validateGraph
|
from modules.workflowAutomation.engine.graphUtils import validateGraph
|
||||||
|
|
||||||
|
|
||||||
def _node(nodeId: str) -> dict:
|
def _node(nodeId: str) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import pytest
|
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():
|
def test_all_nodes_have_usesAi():
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResul
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _FakeInterface:
|
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``
|
Stores workflows by id and records every method call in ``self.calls``
|
||||||
so tests can assert on the parameters the tool layer forwarded.
|
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