463 lines
17 KiB
Python
463 lines
17 KiB
Python
# 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
|