platform-core/modules/workflowAutomation/mainWorkflowAutomation.py
ValueOn AG 4f8473bd70
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 1m2s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
cleaned servicebag and removed servicehub
2026-06-08 23:35:31 +02:00

388 lines
16 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
WorkflowAutomation System Component — n8n-style flow automation.
System-level orchestration infrastructure (not a feature).
Provides lifecycle hooks, service hub, and system templates.
"""
import json
import logging
import uuid
from typing import Dict, List, Any, Optional
from modules.shared.i18nRegistry import t
logger = logging.getLogger(__name__)
COMPONENT_CODE = "workflowAutomation"
REQUIRED_SERVICES = [
{"serviceKey": "chat", "meta": {"usage": "Interfaces, RBAC"}},
{"serviceKey": "utils", "meta": {"usage": "Timestamps, utilities"}},
{"serviceKey": "ai", "meta": {"usage": "AI nodes"}},
{"serviceKey": "extraction", "meta": {"usage": "Workflow method actions"}},
{"serviceKey": "sharepoint", "meta": {"usage": "SharePoint actions"}},
{"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}},
{"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}},
]
def _getRequiredServiceKeys() -> List[str]:
"""Return list of service keys this component requires."""
return [s["serviceKey"] for s in REQUIRED_SERVICES]
def _getWorkflowAutomationServices(
user,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
workflow=None,
):
"""
Get a ServicesBag for WorkflowAutomation using the service center.
Used for methodDiscovery (I/O nodes) and execution (ActionExecutor).
"""
from modules.serviceCenter import getService, ServicesBag
from modules.serviceCenter.context import ServiceCenterContext
_workflow = workflow
if _workflow is None:
_workflow = type(
"_Placeholder",
(),
{"featureCode": COMPONENT_CODE, "id": f"transient-{uuid.uuid4().hex[:12]}", "workflowMode": None, "messages": []},
)()
ctx = ServiceCenterContext(
user=user,
mandate_id=mandateId,
feature_instance_id=featureInstanceId,
workflow=_workflow,
)
return ServicesBag(ctx, lambda key: getService(key, ctx))
# ---------------------------------------------------------------------------
# Lifecycle Hooks
# ---------------------------------------------------------------------------
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all AutoWorkflow data for this mandate."""
from modules.datamodels.datamodelWorkflowAutomation import (
WORKFLOW_AUTOMATION_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
)
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
try:
waDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=WORKFLOW_AUTOMATION_DATABASE,
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None,
)
if not waDb._ensureTableExists(AutoWorkflow):
return
workflows = waDb.getRecordset(AutoWorkflow, recordFilter={
"mandateId": mandateId,
}) or []
totalDeleted = 0
for wf in workflows:
wfId = wf.get("id")
if not wfId:
continue
for v in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
waDb.recordDelete(AutoVersion, v.get("id"))
for run in waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
runId = run.get("id")
for sl in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
waDb.recordDelete(AutoStepLog, sl.get("id"))
waDb.recordDelete(AutoRun, runId)
for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
waDb.recordDelete(AutoTask, task.get("id"))
waDb.recordDelete(AutoWorkflow, wfId)
totalDeleted += 1
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) for mandate {mandateId}")
waDb.close()
except Exception as 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 _registerAgentTools() -> None:
"""Push workflow agent tools into the agent's external tool registry.
Inverts the dependency: workflowAutomation -> serviceCenter (push at boot),
so the agent service never imports workflowAutomation to obtain its tools.
"""
try:
from modules.serviceCenter.services.serviceAgent.externalToolRegistry import registerExternalTools
from modules.workflowAutomation.agentTools import getWorkflowToolDefinitions, TOOLBOX_ID
registerExternalTools(TOOLBOX_ID, getWorkflowToolDefinitions())
except Exception as e:
logger.warning(f"Could not register workflow agent tools (non-critical): {e}")
def onBootstrap() -> None:
"""Seed system workflow templates and sync feature template workflows on boot."""
_migrateRbacNamespace()
_registerAgentTools()
from modules.datamodels.datamodelWorkflowAutomation import WORKFLOW_AUTOMATION_DATABASE, AutoWorkflow
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
try:
waDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=WORKFLOW_AUTOMATION_DATABASE,
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
)
waDb._ensureTableExists(AutoWorkflow)
existing = waDb.getRecordset(AutoWorkflow, recordFilter={
"isTemplate": True,
"templateScope": "system",
})
existingLabels = {r.get("label") if isinstance(r, dict) else getattr(r, "label", "") for r in (existing or [])}
templates = _buildSystemTemplates()
created = 0
for tpl in templates:
if tpl["label"] in existingLabels:
continue
tpl["id"] = str(uuid.uuid4())
waDb.recordCreate(AutoWorkflow, tpl)
created += 1
if created:
logger.info(f"Bootstrapped {created} system workflow template(s)")
from modules.system.registry import loadFeatureMainModules
mainModules = loadFeatureMainModules()
templatesBySourceId: dict = {}
for featureCode, mod in mainModules.items():
getTemplateWorkflowsFn = getattr(mod, "getTemplateWorkflows", None)
if not getTemplateWorkflowsFn:
continue
try:
featureTemplates = getTemplateWorkflowsFn() or []
except Exception:
continue
for tpl in featureTemplates:
tplId = tpl.get("id")
if tplId:
templatesBySourceId[tplId] = tpl
if templatesBySourceId:
updated = 0
for sourceId, tpl in templatesBySourceId.items():
instances = waDb.getRecordset(AutoWorkflow, recordFilter={
"templateSourceId": sourceId,
"isTemplate": False,
})
if not instances:
continue
canonicalGraph = tpl.get("graph", {})
for inst in instances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
targetInstanceId = (
inst.get("targetFeatureInstanceId") if isinstance(inst, dict)
else getattr(inst, "targetFeatureInstanceId", None)
) or ""
graphJson = json.dumps(canonicalGraph)
graphJson = graphJson.replace("{{featureInstanceId}}", targetInstanceId)
newGraph = json.loads(graphJson)
existingGraph = inst.get("graph") if isinstance(inst, dict) else getattr(inst, "graph", None)
if isinstance(existingGraph, str):
try:
existingGraph = json.loads(existingGraph)
except Exception:
existingGraph = None
if existingGraph == newGraph:
continue
waDb.recordModify(AutoWorkflow, instId, {"graph": newGraph})
updated += 1
if updated:
logger.info(f"Synced {updated} workflow(s) with current feature templates")
waDb.close()
except Exception as e:
logger.warning(f"WorkflowAutomation bootstrap failed: {e}")
def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, templateWorkflows: list) -> int:
"""Create workflow instances from template definitions when a feature instance is created."""
from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
from modules.security.rootAccess import getRootUser
from modules.shared.i18nRegistry import resolveText
rootUser = getRootUser()
waInterface = _getWorkflowAutomationInterface(rootUser, mandateId, instanceId)
copied = 0
for template in templateWorkflows:
templateId = template.get("id", "<no-id>")
try:
graphJson = json.dumps(template.get("graph", {}))
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
graph = json.loads(graphJson)
label = resolveText(template.get("label"))
waInterface.createWorkflow({
"label": label,
"graph": graph,
"tags": template.get("tags", [f"feature:{featureCode}"]),
"isTemplate": False,
"templateSourceId": templateId,
"templateScope": "instance",
"active": True,
"targetFeatureInstanceId": instanceId,
"invocations": template.get("invocations", []),
})
copied += 1
except Exception as e:
logger.error(f"onInstanceCreate: failed to copy template '{templateId}': {e}")
return copied
def _buildSystemTemplates():
"""Build the graph definitions for platform system templates."""
return [
{
"label": t("Personal Assistant: E-Mail-Antwort-Drafting"),
"mandateId": None,
"featureInstanceId": None,
"isTemplate": True,
"templateScope": "system",
"sharedReadOnly": True,
"active": False,
"graph": {
"nodes": [
{"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Täglicher Check", "parameters": {}},
{"id": "n2", "type": "email.checkEmail", "x": 300, "y": 200, "title": "Mailbox prüfen", "parameters": {}},
{"id": "n3", "type": "flow.loop", "x": 550, "y": 200, "title": "Pro E-Mail", "parameters": {"items": {"type": "ref", "nodeId": "n2", "path": ["emails"]}, "concurrency": 1}},
{"id": "n4", "type": "ai.prompt", "x": 800, "y": 200, "title": "Analyse: Antwort nötig?", "parameters": {}},
{"id": "n5", "type": "flow.ifElse", "x": 1050, "y": 200, "title": "Antwort nötig?", "parameters": {}},
{"id": "n6", "type": "ai.prompt", "x": 1300, "y": 100, "title": "Kontext abrufen & Antwort formulieren", "parameters": {}},
{"id": "n7", "type": "email.draftEmail", "x": 1550, "y": 100, "title": "Draft erstellen", "parameters": {}},
],
"connections": [
{"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0},
{"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0},
{"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0},
{"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0},
{"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0},
{"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0},
],
},
"invocations": [{"type": "schedule", "cronExpression": "0 8 * * 1-5"}],
},
{
"label": t("Treuhand: PDF-Klassifizierung & Trustee-Import"),
"mandateId": None,
"featureInstanceId": None,
"isTemplate": True,
"templateScope": "system",
"sharedReadOnly": True,
"active": False,
"graph": {
"nodes": [
{"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Geplanter Import", "parameters": {}},
{"id": "n2", "type": "sharepoint.listFiles", "x": 300, "y": 200, "title": "SharePoint Ordner lesen", "parameters": {}},
{"id": "n3", "type": "flow.loop", "x": 550, "y": 200, "title": "Pro Dokument", "parameters": {"items": {"type": "ref", "nodeId": "n2", "path": ["files"]}, "concurrency": 1}},
{"id": "n4", "type": "sharepoint.readFile", "x": 800, "y": 200, "title": "PDF-Inhalt lesen", "parameters": {}},
{"id": "n5", "type": "ai.prompt", "x": 1050, "y": 200, "title": "Typ klassifizieren (Rechnung, Beleg, Bankauszug, Vertrag, etc.)", "parameters": {}},
{"id": "n6", "type": "trustee.extractFromFiles", "x": 1300, "y": 200, "title": "Dokument extrahieren", "parameters": {}},
{"id": "n7", "type": "trustee.processDocuments", "x": 1550, "y": 200, "title": "In Trustee einlesen", "parameters": {}},
],
"connections": [
{"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0},
{"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0},
{"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0},
{"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0},
{"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0},
{"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0},
],
},
"invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}],
},
]