388 lines
16 KiB
Python
388 lines
16 KiB
Python
# Copyright (c) 2026 PowerOn AG
|
|
# 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,
|
|
mandateId=mandateId,
|
|
featureInstanceId=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, "item" FROM "AccessRule" WHERE "item" LIKE %s',
|
|
(f"{oldPrefix}%",),
|
|
)
|
|
rows = cur.fetchall()
|
|
if not rows:
|
|
continue
|
|
|
|
for rowId, item in rows:
|
|
newKey = item.replace(oldPrefix, newPrefix, 1)
|
|
cur.execute(
|
|
'UPDATE "AccessRule" SET "item" = %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"}],
|
|
},
|
|
]
|