# 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", "") 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"}], }, ]