# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Interface for Automation2 feature - Workflows, Runs, Human Tasks. Uses PostgreSQL poweron_automation2 database. """ import base64 import logging import uuid from typing import Dict, Any, List, Optional def _make_json_serializable(obj: Any) -> Any: """ Recursively convert bytes to base64 strings so structures can be JSON-serialized for storage in JSONB columns. """ if isinstance(obj, bytes): return base64.b64encode(obj).decode("ascii") if isinstance(obj, dict): return {k: _make_json_serializable(v) for k, v in obj.items()} if isinstance(obj, list): return [_make_json_serializable(v) for v in obj] return obj from modules.datamodels.datamodelUam import User from modules.features.automation2.datamodelFeatureAutomation2 import ( Automation2Workflow, Automation2WorkflowRun, Automation2HumanTask, ) from modules.features.automation2.entryPoints import normalize_invocations_list from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG logger = logging.getLogger(__name__) def getAutomation2Interface( currentUser: User, mandateId: str, featureInstanceId: str, ) -> "Automation2Objects": """Factory for Automation2 interface with user context.""" return Automation2Objects( currentUser=currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId, ) def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: """ Get all active Automation2 workflows that have a schedule entry point (primary invocation). Used by the scheduler to register cron jobs. Does not filter by mandate/instance. """ dbHost = APP_CONFIG.get("DB_HOST", "localhost") dbDatabase = "poweron_automation2" 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)) connector = DatabaseConnector( dbHost=dbHost, dbDatabase=dbDatabase, dbUser=dbUser, dbPassword=dbPassword, dbPort=dbPort, userId=None, ) if not connector._ensureTableExists(Automation2Workflow): logger.warning("Automation2 schedule: table Automation2Workflow does not exist") return [] # Don't filter by active in SQL: existing workflows may have active=NULL. # Treat NULL as active; skip only when active is explicitly False. records = connector.getRecordset( Automation2Workflow, recordFilter=None, ) raw_count = len(records) if records else 0 result = [] for r in records or []: if r.get("active") is False: continue wf = dict(r) wf["invocations"] = normalize_invocations_list(wf.get("invocations")) invocations = wf.get("invocations") or [] primary = invocations[0] if invocations else {} if not isinstance(primary, dict): primary = {} # Cron comes from graph start node params (trigger.schedule) graph = wf.get("graph") or {} nodes = graph.get("nodes") or [] cron = None for n in nodes: if n.get("type") == "trigger.schedule": params = n.get("parameters") or {} cron = params.get("cron") if cron: break if not cron or not isinstance(cron, str) or not cron.strip(): continue # Prefer invocations; if graph has trigger.schedule but invocations say manual, still schedule if primary.get("kind") == "schedule" and primary.get("enabled", True): entry_point_id = primary.get("id") elif invocations and isinstance(invocations[0], dict) and invocations[0].get("id"): entry_point_id = invocations[0].get("id") else: entry_point_id = str(uuid.uuid4()) result.append({ "workflowId": wf.get("id"), "mandateId": wf.get("mandateId"), "featureInstanceId": wf.get("featureInstanceId"), "entryPointId": entry_point_id, "cron": cron.strip(), "workflow": wf, }) logger.info( "Automation2 schedule: DB has %d workflow(s), %d active with trigger.schedule+cron", raw_count, len(result), ) return result class Automation2Objects: """Interface for Automation2 database operations.""" def __init__( self, currentUser: User, mandateId: str, featureInstanceId: str, ): self.currentUser = currentUser self.mandateId = mandateId self.featureInstanceId = featureInstanceId self.userId = currentUser.id if currentUser else None self._init_db() if hasattr(self.db, "updateContext") and self.userId: self.db.updateContext(self.userId) def _init_db(self): """Initialize database connection to poweron_automation2.""" dbHost = APP_CONFIG.get("DB_HOST", "localhost") dbDatabase = "poweron_automation2" 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)) self.db = DatabaseConnector( dbHost=dbHost, dbDatabase=dbDatabase, dbUser=dbUser, dbPassword=dbPassword, dbPort=dbPort, userId=self.userId, ) logger.debug("Automation2 database initialized for user %s", self.userId) # ------------------------------------------------------------------------- # Workflow CRUD # ------------------------------------------------------------------------- def getWorkflows(self, active: Optional[bool] = None) -> List[Dict[str, Any]]: """Get all workflows for this mandate and feature instance. Optional active filter: True=only active, False=only inactive, None=all. """ if not self.db._ensureTableExists(Automation2Workflow): return [] rf: Dict[str, Any] = { "mandateId": self.mandateId, "featureInstanceId": self.featureInstanceId, } if active is not None: rf["active"] = active records = self.db.getRecordset( Automation2Workflow, recordFilter=rf, ) rows = [dict(r) for r in records] if records else [] for wf in rows: wf["invocations"] = normalize_invocations_list(wf.get("invocations")) return rows def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]: """Get a single workflow by ID.""" if not self.db._ensureTableExists(Automation2Workflow): return None records = self.db.getRecordset( Automation2Workflow, recordFilter={ "id": workflowId, "mandateId": self.mandateId, "featureInstanceId": self.featureInstanceId, }, ) if not records: return None wf = dict(records[0]) wf["invocations"] = normalize_invocations_list(wf.get("invocations")) return wf def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]: """Create a new workflow.""" if "id" not in data or not data.get("id"): data["id"] = str(uuid.uuid4()) data["mandateId"] = self.mandateId data["featureInstanceId"] = self.featureInstanceId if "active" not in data or data.get("active") is None: data["active"] = True data["invocations"] = normalize_invocations_list(data.get("invocations")) created = self.db.recordCreate(Automation2Workflow, data) out = dict(created) out["invocations"] = normalize_invocations_list(out.get("invocations")) try: from modules.shared.callbackRegistry import callbackRegistry callbackRegistry.trigger("automation2.workflow.changed") except Exception: pass return out def updateWorkflow(self, workflowId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Update an existing workflow.""" existing = self.getWorkflow(workflowId) if not existing: return None # Don't overwrite mandateId/featureInstanceId data.pop("mandateId", None) data.pop("featureInstanceId", None) if "invocations" in data: data["invocations"] = normalize_invocations_list(data.get("invocations")) updated = self.db.recordModify(Automation2Workflow, workflowId, data) out = dict(updated) out["invocations"] = normalize_invocations_list(out.get("invocations")) try: from modules.shared.callbackRegistry import callbackRegistry callbackRegistry.trigger("automation2.workflow.changed") except Exception: pass return out def deleteWorkflow(self, workflowId: str) -> bool: """Delete a workflow.""" existing = self.getWorkflow(workflowId) if not existing: return False self.db.recordDelete(Automation2Workflow, workflowId) try: from modules.shared.callbackRegistry import callbackRegistry callbackRegistry.trigger("automation2.workflow.changed") except Exception: pass return True # ------------------------------------------------------------------------- # Workflow Runs # ------------------------------------------------------------------------- def createRun(self, workflowId: str, nodeOutputs: Dict = None, context: Dict = None) -> Dict[str, Any]: """Create a new workflow run.""" data = { "id": str(uuid.uuid4()), "workflowId": workflowId, "status": "running", "nodeOutputs": _make_json_serializable(nodeOutputs or {}), "currentNodeId": None, "context": context or {}, } created = self.db.recordCreate(Automation2WorkflowRun, data) return dict(created) def getRun(self, runId: str) -> Optional[Dict[str, Any]]: """Get a run by ID.""" if not self.db._ensureTableExists(Automation2WorkflowRun): return None records = self.db.getRecordset( Automation2WorkflowRun, recordFilter={"id": runId}, ) if not records: return None return dict(records[0]) def updateRun( self, runId: str, status: str = None, nodeOutputs: Dict = None, currentNodeId: str = None, context: Dict = None, ) -> Optional[Dict[str, Any]]: """Update a run.""" run = self.getRun(runId) if not run: return None updates = {} if status is not None: updates["status"] = status if nodeOutputs is not None: updates["nodeOutputs"] = _make_json_serializable(nodeOutputs) if currentNodeId is not None: updates["currentNodeId"] = currentNodeId if context is not None: updates["context"] = context if not updates: return run updated = self.db.recordModify(Automation2WorkflowRun, runId, updates) return dict(updated) def getRunsByWorkflow(self, workflowId: str) -> List[Dict[str, Any]]: """Get all runs for a workflow.""" if not self.db._ensureTableExists(Automation2WorkflowRun): return [] records = self.db.getRecordset( Automation2WorkflowRun, recordFilter={"workflowId": workflowId}, ) return [dict(r) for r in records] if records else [] def getRecentCompletedRuns(self, limit: int = 20) -> List[Dict[str, Any]]: """Get recently completed runs for workflows in this instance (for output display).""" if not self.db._ensureTableExists(Automation2WorkflowRun): return [] workflows = self.getWorkflows() wf_ids = [w["id"] for w in workflows if w.get("id")] if not wf_ids: return [] records = self.db.getRecordset( Automation2WorkflowRun, recordFilter={"status": "completed"}, ) if not records: return [] runs = [dict(r) for r in records if r.get("workflowId") in wf_ids] wf_by_id = {w["id"]: w for w in workflows} for r in runs: wf = wf_by_id.get(r.get("workflowId"), {}) r["workflowLabel"] = wf.get("label") or r.get("workflowId", "") runs.sort( key=lambda x: (x.get("sysModifiedAt") or x.get("sysCreatedAt") or 0), reverse=True, ) return runs[:limit] def getRunsWaitingForEmail(self) -> List[Dict[str, Any]]: """Get all paused runs waiting for a new email (for background poller).""" if not self.db._ensureTableExists(Automation2WorkflowRun): return [] records = self.db.getRecordset( Automation2WorkflowRun, recordFilter={"status": "paused"}, ) if not records: return [] result = [] for r in records: rec = dict(r) ctx = rec.get("context") or {} if ctx.get("waitReason") == "email": result.append(rec) return result # ------------------------------------------------------------------------- # Human Tasks # ------------------------------------------------------------------------- def createTask( self, runId: str, workflowId: str, nodeId: str, nodeType: str, config: Dict[str, Any], assigneeId: str = None, ) -> Dict[str, Any]: """Create a human task.""" data = { "id": str(uuid.uuid4()), "runId": runId, "workflowId": workflowId, "nodeId": nodeId, "nodeType": nodeType, "config": config, "assigneeId": assigneeId, "status": "pending", "result": None, } created = self.db.recordCreate(Automation2HumanTask, data) return dict(created) def getTask(self, taskId: str) -> Optional[Dict[str, Any]]: """Get a task by ID.""" if not self.db._ensureTableExists(Automation2HumanTask): return None records = self.db.getRecordset( Automation2HumanTask, recordFilter={"id": taskId}, ) if not records: return None return dict(records[0]) def updateTask(self, taskId: str, status: str = None, result: Dict = None) -> Optional[Dict[str, Any]]: """Update a task (e.g. complete with result).""" task = self.getTask(taskId) if not task: return None updates = {} if status is not None: updates["status"] = status if result is not None: updates["result"] = result if not updates: return task updated = self.db.recordModify(Automation2HumanTask, taskId, updates) return dict(updated) def getTasks( self, workflowId: str = None, runId: str = None, status: str = None, assigneeId: str = None, ) -> List[Dict[str, Any]]: """Get tasks with optional filters. When assigneeId is set: returns tasks assigned to that user OR unassigned (so schedule tasks show up). When assigneeId is None: returns all tasks. """ if not self.db._ensureTableExists(Automation2HumanTask): return [] base_rf: Dict[str, Any] = {} if workflowId: base_rf["workflowId"] = workflowId if runId: base_rf["runId"] = runId if status: base_rf["status"] = status if assigneeId: rf_assigned = {**base_rf, "assigneeId": assigneeId} rf_unassigned = {**base_rf, "assigneeId": None} records1 = self.db.getRecordset(Automation2HumanTask, recordFilter=rf_assigned) records2 = self.db.getRecordset(Automation2HumanTask, recordFilter=rf_unassigned) seen = set() items = [] for r in (records1 or []) + (records2 or []): rec = dict(r) tid = rec.get("id") if tid and tid not in seen: seen.add(tid) items.append(rec) else: records = self.db.getRecordset( Automation2HumanTask, recordFilter=base_rf if base_rf else None, ) items = [dict(r) for r in records] if records else [] workflows = {w["id"]: w for w in self.getWorkflows()} filtered = [t for t in items if t.get("workflowId") in workflows] return filtered