before refactory workflowAutomation
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m2s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s

This commit is contained in:
ValueOn AG 2026-06-07 22:26:18 +02:00
parent 2b208ee504
commit 39aba4cca8
22 changed files with 713 additions and 290 deletions

23
app.py
View file

@ -482,6 +482,14 @@ async def lifespan(app: FastAPI):
pass pass
eventManager.start() eventManager.start()
# --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) ---
try:
from modules.workflows.scheduler.mainScheduler import start as _startWorkflowScheduler
_startWorkflowScheduler(eventUser)
logger.info("WorkflowAutomation scheduler started (system lifespan)")
except Exception as e:
logger.error(f"WorkflowAutomation scheduler failed to start: {e}")
# Register audit log cleanup scheduler # Register audit log cleanup scheduler
from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler() registerAuditLogCleanupScheduler()
@ -562,6 +570,18 @@ async def lifespan(app: FastAPI):
# 3. Stop scheduler (removes all pending cron/interval jobs) # 3. Stop scheduler (removes all pending cron/interval jobs)
eventManager.stop() eventManager.stop()
# 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan)
try:
from modules.workflows.scheduler.mainScheduler import stop as _stopWorkflowScheduler
_stopWorkflowScheduler()
except Exception as e:
logger.warning(f"WorkflowAutomation scheduler stop failed: {e}")
try:
from modules.features.graphicalEditor.emailPoller import stop as _stopEmailPoller
_stopEmailPoller(eventUser)
except Exception as e:
logger.warning(f"Email poller stop failed: {e}")
# 4. Stop Feature Containers (Plug&Play) # 4. Stop Feature Containers (Plug&Play)
try: try:
mainModules = loadFeatureMainModules() mainModules = loadFeatureMainModules()
@ -849,6 +869,9 @@ app.include_router(workflowDashboardRouter)
from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter
app.include_router(automationWorkspaceRouter) app.include_router(automationWorkspaceRouter)
from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter
app.include_router(workflowAutomationRouter)
# ============================================================================ # ============================================================================
# PLUG&PLAY FEATURE ROUTERS # PLUG&PLAY FEATURE ROUTERS
# Dynamically load routers from feature containers in modules/features/ # Dynamically load routers from feature containers in modules/features/

View file

@ -158,6 +158,54 @@ NAVIGATION_SECTIONS = [
}, },
], ],
}, },
# --- Workflow-Automation (System-Komponente, cross-mandate) ---
{
"id": "workflowAutomation",
"title": t("Workflow-Automation"),
"order": 25,
"items": [
{
"id": "wa-workflows",
"objectKey": "ui.system.workflowAutomation.workflows",
"label": t("Workflows"),
"icon": "FaSitemap",
"path": "/workflow-automation?tab=workflows",
"order": 10,
},
{
"id": "wa-editor",
"objectKey": "ui.system.workflowAutomation.editor",
"label": t("Editor"),
"icon": "FaProjectDiagram",
"path": "/workflow-automation?tab=editor",
"order": 20,
},
{
"id": "wa-templates",
"objectKey": "ui.system.workflowAutomation.templates",
"label": t("Vorlagen"),
"icon": "FaCopy",
"path": "/workflow-automation?tab=templates",
"order": 30,
},
{
"id": "wa-runs",
"objectKey": "ui.system.workflowAutomation.runs",
"label": t("Läufe"),
"icon": "FaPlay",
"path": "/workflow-automation?tab=runs",
"order": 40,
},
{
"id": "wa-tasks",
"objectKey": "ui.system.workflowAutomation.tasks",
"label": t("Tasks"),
"icon": "FaTasks",
"path": "/workflow-automation?tab=tasks",
"order": 50,
},
],
},
# --- Administration (with subgroups) --- # --- Administration (with subgroups) ---
{ {
"id": "admin", "id": "admin",

View file

@ -77,14 +77,26 @@ class AutoWorkflow(PowerOnModel):
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}, "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
}, },
) )
featureInstanceId: str = Field( featureInstanceId: Optional[str] = Field(
description="Feature instance ID (GE owner instance / RBAC scope)", default=None,
description="Feature instance ID (legacy GE owner — being phased out; NULL for mandate-level workflows)",
json_schema_extra={ json_schema_extra={
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False, "frontend_required": False,
"label": "Feature-Instanz-ID", "label": "Feature-Instanz-ID",
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}, "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True},
},
)
runAsPrincipal: Optional[str] = Field(
default=None,
description="Identity (userId or service-account) under which this workflow executes. Governs RBAC for data access at runtime.",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False,
"label": "Ausführungsidentität",
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True},
}, },
) )
targetFeatureInstanceId: Optional[str] = Field( targetFeatureInstanceId: Optional[str] = Field(

View file

@ -144,3 +144,28 @@ class BillingContextError(Exception):
def __init__(self, message: str = None): def __init__(self, message: str = None):
self.message = message or "Billing context incomplete - AI call blocked" self.message = message or "Billing context incomplete - AI call blocked"
super().__init__(self.message) super().__init__(self.message)
# ============================================================================
# Workflow execution pause exceptions
# (Canonical location — formerly in automation2/executors/inputExecutor.py)
# ============================================================================
class PauseForHumanTaskError(Exception):
"""Raised when execution must pause for a human task. Contains runId, taskId."""
def __init__(self, runId: str, taskId: str, nodeId: str):
self.runId = runId
self.taskId = taskId
self.nodeId = nodeId
super().__init__(f"Pause for human task {taskId} (run {runId}, node {nodeId})")
class PauseForEmailWaitError(Exception):
"""Raised when execution must pause waiting for a new email. Background poller will resume."""
def __init__(self, runId: str, nodeId: str, waitConfig: Dict[str, Any]):
self.runId = runId
self.nodeId = nodeId
self.waitConfig = waitConfig
super().__init__(f"Pause for email wait (run {runId}, node {nodeId})")

View file

@ -44,7 +44,7 @@ _USER = {
_FEATURES_HAPPYLIFE = [ _FEATURES_HAPPYLIFE = [
{"code": "workspace", "label": "Dokumentenablage"}, {"code": "workspace", "label": "Dokumentenablage"},
{"code": "trustee", "label": "Buchhaltung"}, {"code": "trustee", "label": "Buchhaltung"},
{"code": "graphicalEditor", "label": "Automationen"}, {"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component
{"code": "neutralization", "label": "Datenschutz"}, {"code": "neutralization", "label": "Datenschutz"},
] ]
_FEATURES_ALPINA = [ _FEATURES_ALPINA = [
@ -52,7 +52,7 @@ _FEATURES_ALPINA = [
{"code": "trustee", "label": "BUHA Müller Immobilien GmbH"}, {"code": "trustee", "label": "BUHA Müller Immobilien GmbH"},
{"code": "trustee", "label": "BUHA Schneider Gastro AG"}, {"code": "trustee", "label": "BUHA Schneider Gastro AG"},
{"code": "trustee", "label": "BUHA Weber Consulting"}, {"code": "trustee", "label": "BUHA Weber Consulting"},
{"code": "graphicalEditor", "label": "Automationen"}, {"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component
{"code": "neutralization", "label": "Datenschutz"}, {"code": "neutralization", "label": "Datenschutz"},
] ]

View file

@ -49,7 +49,7 @@ _USER = {
_FEATURES_PWG = [ _FEATURES_PWG = [
{"code": "workspace", "label": "Dokumentenablage PWG"}, {"code": "workspace", "label": "Dokumentenablage PWG"},
{"code": "trustee", "label": "Buchhaltung PWG"}, {"code": "trustee", "label": "Buchhaltung PWG"},
{"code": "graphicalEditor", "label": "PWG Automationen"}, {"code": "graphicalEditor", "label": "PWG Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component
{"code": "neutralization", "label": "Datenschutz"}, {"code": "neutralization", "label": "Datenschutz"},
] ]

View file

@ -26,25 +26,6 @@ REQUIRED_SERVICES = [
{"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}}, {"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}},
] ]
FEATURE_LABEL = t("Grafischer Editor", context="UI") FEATURE_LABEL = t("Grafischer Editor", context="UI")
FEATURE_ICON = "mdi-sitemap"
UI_OBJECTS = [
{
"objectKey": "ui.feature.graphicalEditor.editor",
"label": t("Editor", context="UI"),
"meta": {"area": "editor"}
},
{
"objectKey": "ui.feature.graphicalEditor.templates",
"label": t("Vorlagen", context="UI"),
"meta": {"area": "templates"}
},
{
"objectKey": "ui.feature.graphicalEditor.workflows-tasks",
"label": t("Tasks", context="UI"),
"meta": {"area": "tasks"}
},
]
RESOURCE_OBJECTS = [ RESOURCE_OBJECTS = [
{ {
@ -64,41 +45,6 @@ RESOURCE_OBJECTS = [
}, },
] ]
TEMPLATE_ROLES = [
{
"roleLabel": "graphicalEditor-viewer",
"description": "Grafischer Editor Betrachter - Workflows ansehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.templates", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
],
},
{
"roleLabel": "graphicalEditor-user",
"description": "Grafischer Editor Benutzer - Flow-Builder nutzen",
"accessRules": [
{"context": "UI", "item": "ui.feature.graphicalEditor.editor", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.templates", "view": True},
{"context": "RESOURCE", "item": "resource.feature.graphicalEditor.dashboard", "view": True},
{"context": "RESOURCE", "item": "resource.feature.graphicalEditor.node-types", "view": True},
{"context": "RESOURCE", "item": "resource.feature.graphicalEditor.execute", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
],
},
{
"roleLabel": "graphicalEditor-admin",
"description": "Grafischer Editor Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
],
},
]
def getRequiredServiceKeys() -> List[str]: def getRequiredServiceKeys() -> List[str]:
@ -186,28 +132,6 @@ class _GraphicalEditorServiceHub:
generation = None generation = None
async def onStart(eventUser) -> None:
"""Feature startup: start consolidated scheduler."""
from modules.workflows.scheduler.mainScheduler import start as startScheduler
startScheduler(eventUser)
async def onStop(eventUser) -> None:
"""Feature shutdown - stop scheduler and email poller."""
from modules.workflows.scheduler.mainScheduler import stop as stopScheduler
stopScheduler()
from modules.features.graphicalEditor.emailPoller import stop as stopEmailPoller
stopEmailPoller(eventUser)
def getFeatureDefinition() -> Dict[str, Any]:
"""Return the feature definition for registration."""
return {
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
"autoCreateInstance": False,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -473,125 +397,6 @@ def _buildSystemTemplates():
] ]
def getUiObjects() -> List[Dict[str, Any]]:
"""Return UI objects for RBAC catalog registration."""
return UI_OBJECTS
def getResourceObjects() -> List[Dict[str, Any]]: def getResourceObjects() -> List[Dict[str, Any]]:
"""Return resource objects for RBAC catalog registration.""" """Return resource objects for RBAC catalog registration."""
return RESOURCE_OBJECTS return RESOURCE_OBJECTS
def getTemplateRoles() -> List[Dict[str, Any]]:
"""Return template roles for this feature."""
return TEMPLATE_ROLES
def registerFeature(catalogService) -> bool:
"""Register this feature's RBAC objects in the catalog."""
try:
for uiObj in UI_OBJECTS:
catalogService.registerUiObject(
featureCode=FEATURE_CODE,
objectKey=uiObj["objectKey"],
label=uiObj["label"],
meta=uiObj.get("meta")
)
for resObj in RESOURCE_OBJECTS:
catalogService.registerResourceObject(
featureCode=FEATURE_CODE,
objectKey=resObj["objectKey"],
label=resObj["label"],
meta=resObj.get("meta")
)
_syncTemplateRolesToDb()
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
return True
except Exception as e:
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False
def _syncTemplateRolesToDb() -> int:
"""Sync template roles and their AccessRules to database.
Also syncs rules to mandate-specific roles (same roleLabel) so new UI objects
become visible after gateway restart without manual role update.
"""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
existingLabels = {r.roleLabel: str(r.id) for r in existingRoles if r.mandateId is None}
created = 0
for template in TEMPLATE_ROLES:
roleLabel = template["roleLabel"]
if roleLabel in existingLabels:
roleId = existingLabels[roleLabel]
else:
newRole = Role(
roleLabel=roleLabel,
description=coerce_text_multilingual(template.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,
isSystemRole=False
)
rec = rootInterface.db.recordCreate(Role, newRole.model_dump())
roleId = rec.get("id")
created += 1
logger.info(f"Created template role '{roleLabel}' for {FEATURE_CODE}")
_ensureAccessRulesForRole(rootInterface, roleId, template.get("accessRules", []))
for r in existingRoles:
if r.mandateId and r.roleLabel == roleLabel:
added = _ensureAccessRulesForRole(
rootInterface, str(r.id), template.get("accessRules", [])
)
if added:
logger.debug(f"Added {added} access rules to mandate role {r.id}")
return created
except Exception as e:
logger.warning(f"Template role sync for {FEATURE_CODE}: {e}")
return 0
def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int:
"""Ensure AccessRules exist for a role based on templates."""
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
existingRules = rootInterface.getAccessRulesByRole(roleId)
existingSignatures = {
(r.context.value if r.context else None, r.item)
for r in existingRules
}
created = 0
for t in ruleTemplates:
context = t.get("context", "UI")
item = t.get("item")
sig = (context, item)
if sig in existingSignatures:
continue
ctx_enum = (
AccessRuleContext.UI if context == "UI" else
AccessRuleContext.DATA if context == "DATA" else
AccessRuleContext.RESOURCE if context == "RESOURCE" else context
)
newRule = AccessRule(
roleId=roleId,
context=ctx_enum,
item=item,
view=t.get("view", False),
read=t.get("read"),
create=t.get("create"),
update=t.get("update"),
delete=t.get("delete"),
)
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
created += 1
return created

View file

@ -1,7 +1,10 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
GraphicalEditor routes - node-types, execute, workflows, runs, tasks, connections, browse. DEPRECATED: These per-instance routes are superseded by /api/workflow-automation/
(routeWorkflowAutomation.py). Kept for backward compatibility during migration.
Original: GraphicalEditor routes - node-types, execute, workflows, runs, tasks, connections, browse.
""" """
import asyncio import asyncio
@ -644,7 +647,8 @@ def get_templates(
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
enrichRowsWithFkLabels(templates, AutoWorkflow, db=iface.db) from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
enrichRowsWithFkLabels(templates, AutoWorkflow, db=_getRootIface().db)
if mode == "filterValues": if mode == "filterValues":
if not column: if not column:

View file

@ -1610,7 +1610,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
"resource.store.workspace", "resource.store.workspace",
"resource.store.commcoach", "resource.store.commcoach",
"resource.store.trustee", "resource.store.trustee",
"resource.store.graphicalEditor", "resource.store.graphicalEditor", # DEPRECATED: will move with WorkflowAutomation code restructuring
] ]
storeRules = [] storeRules = []

View file

@ -21,12 +21,12 @@ from slowapi.util import get_remote_address
from modules.auth.authentication import getRequestContext, RequestContext from modules.auth.authentication import getRequestContext, RequestContext
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( from modules.datamodels.datamodelWorkflowAutomation import (
AutoRun, AutoRun,
AutoStepLog, AutoStepLog,
AutoWorkflow, AutoWorkflow,
GRAPHICAL_EDITOR_DATABASE,
) )
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext
@ -40,7 +40,7 @@ router = APIRouter(prefix="/api/automations/runs", tags=["AutomationWorkspace"])
def _getDb() -> DatabaseConnector: def _getDb() -> DatabaseConnector:
return DatabaseConnector( return DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=graphicalEditorDatabase, dbDatabase=GRAPHICAL_EDITOR_DATABASE,
dbUser=APP_CONFIG.get("DB_USER"), dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),

View file

@ -105,9 +105,6 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
elif featureCode == "realestate": elif featureCode == "realestate":
from modules.features.realEstate.mainRealEstate import UI_OBJECTS from modules.features.realEstate.mainRealEstate import UI_OBJECTS
return UI_OBJECTS return UI_OBJECTS
elif featureCode == "graphicalEditor":
from modules.features.graphicalEditor.mainGraphicalEditor import UI_OBJECTS
return UI_OBJECTS
elif featureCode == "teamsbot": elif featureCode == "teamsbot":
from modules.features.teamsbot.mainTeamsbot import UI_OBJECTS from modules.features.teamsbot.mainTeamsbot import UI_OBJECTS
return UI_OBJECTS return UI_OBJECTS
@ -841,7 +838,7 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]:
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelPagination import PaginationParams from modules.datamodels.datamodelPagination import PaginationParams
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( from modules.datamodels.datamodelWorkflowAutomation import (
AutoWorkflow, AutoRun, AutoWorkflow, AutoRun,
) )

View file

@ -0,0 +1,453 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Mandatsweite WorkflowAutomation API.
System-level API for workflows, runs, tasks scoped by mandate membership,
not by graphicalEditor FeatureInstance. Parallel to the legacy per-instance
API in routeFeatureGraphicalEditor.py during the migration period.
RBAC model:
- Read: mandate membership (user sees workflows in own mandates)
- Write/Execute: mandate admin or isPlatformAdmin
- isPlatformAdmin bypasses all checks
"""
import json
import logging
import time
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from slowapi import Limiter
from slowapi.util import get_remote_address
from modules.auth.authentication import getRequestContext, RequestContext
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelWorkflowAutomation import (
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
GRAPHICAL_EDITOR_DATABASE,
)
from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeWorkflowAutomation")
logger = logging.getLogger(__name__)
limiter = Limiter(key_func=get_remote_address)
router = APIRouter(prefix="/api/workflow-automation", tags=["WorkflowAutomation"])
# ---------------------------------------------------------------------------
# DB + RBAC helpers
# ---------------------------------------------------------------------------
def _getDb() -> DatabaseConnector:
return DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=GRAPHICAL_EDITOR_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,
)
def _getUserMandateIds(userId: str) -> List[str]:
rootIface = getRootInterface()
memberships = rootIface.getUserMandates(userId)
return [um.mandateId for um in memberships if um.mandateId and um.enabled]
def _getAdminMandateIds(userId: str, mandateIds: List[str]) -> List[str]:
if not mandateIds:
return []
rootIface = getRootInterface()
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
memberships = rootIface.db.getRecordset(
UserMandate,
recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True},
)
if not memberships:
return []
umIdToMandateId: Dict[str, str] = {}
for m in memberships:
row = m if isinstance(m, dict) else m.__dict__
um_id = row.get("id")
mid = row.get("mandateId")
if um_id and mid:
umIdToMandateId[str(um_id)] = str(mid)
userMandateIds = list(umIdToMandateId.keys())
allRoles = rootIface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateIds},
)
if not allRoles:
return []
roleIds: set = set()
roleToMandate: Dict[str, set] = {}
for r in allRoles:
row = r if isinstance(r, dict) else r.__dict__
rid = row.get("roleId")
um_id = row.get("userMandateId")
mid = umIdToMandateId.get(str(um_id)) if um_id else None
if rid and mid:
roleIds.add(rid)
roleToMandate.setdefault(rid, set()).add(mid)
if not roleIds:
return []
from modules.datamodels.datamodelRbac import Role
roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)})
adminMandates: set = set()
for role in (roleRecords or []):
row = role if isinstance(role, dict) else role.__dict__
rid = row.get("id")
if not rid or rid not in roleToMandate:
continue
if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"):
adminMandates.update(roleToMandate[rid])
return [mid for mid in mandateIds if mid in adminMandates]
def _validateWorkflowAccess(
context: RequestContext,
workflow: Optional[Dict[str, Any]],
action: str = "read",
) -> None:
"""Validate access to a workflow based on mandate membership + admin status.
Actions: 'read' (mandate member), 'write'/'execute'/'delete' (mandate admin or platform admin).
Raises HTTPException(403) on denial.
"""
if context.isPlatformAdmin:
return
userId = str(context.user.id) if context.user else None
if not userId:
raise HTTPException(status_code=403, detail="Authentication required")
if workflow is None:
raise HTTPException(status_code=404, detail="Workflow not found")
wfMandateId = workflow.get("mandateId") or ""
if not wfMandateId:
if action == "read":
return
raise HTTPException(status_code=403, detail="Workflow has no mandate — admin only")
userMandateIds = _getUserMandateIds(userId)
if wfMandateId not in userMandateIds:
raise HTTPException(status_code=403, detail="Not a member of the workflow's mandate")
if action == "read":
return
adminMandateIds = _getAdminMandateIds(userId, [wfMandateId])
if wfMandateId not in adminMandateIds:
raise HTTPException(
status_code=403,
detail=f"Mandate admin required for '{action}' on workflows",
)
def _scopedWorkflowFilter(context: RequestContext) -> Optional[Dict[str, Any]]:
"""Build DB filter for listing workflows: mandate-scoped for members, None for sysadmin."""
if context.isPlatformAdmin:
return None
userId = str(context.user.id) if context.user else None
if not userId:
return {"mandateId": "__impossible__"}
mandateIds = _getUserMandateIds(userId)
if mandateIds:
return {"mandateId": mandateIds}
return {"mandateId": "__impossible__"}
def _scopedRunFilter(context: RequestContext) -> Optional[Dict[str, Any]]:
"""Build DB filter for listing runs: admin sees mandate runs, user sees own."""
if context.isPlatformAdmin:
return None
userId = str(context.user.id) if context.user else None
if not userId:
return {"ownerId": "__impossible__"}
mandateIds = _getUserMandateIds(userId)
adminMandateIds = _getAdminMandateIds(userId, mandateIds)
if adminMandateIds:
return {"mandateId": adminMandateIds}
return {"ownerId": userId}
def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
if not pagination:
return None
try:
d = json.loads(pagination)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid pagination JSON")
if not d:
return None
return normalize_pagination_dict(d)
# ---------------------------------------------------------------------------
# Workflow CRUD
# ---------------------------------------------------------------------------
@router.get("/workflows")
async def _listWorkflows(
request: RequestContext = Depends(getRequestContext),
pagination: Optional[str] = Query(default=None),
mandateId: Optional[str] = Query(default=None),
):
db = _getDb()
try:
db._ensureTableExists(AutoWorkflow)
scopeFilter = _scopedWorkflowFilter(request)
if mandateId and scopeFilter is not None:
if mandateId not in (scopeFilter.get("mandateId") or []):
return {"items": [], "total": 0}
scopeFilter = {"mandateId": mandateId}
elif mandateId and scopeFilter is None:
scopeFilter = {"mandateId": mandateId}
params = _parsePagination(pagination)
records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter, pagination=params)
total = db.getRecordCount(AutoWorkflow, recordFilter=scopeFilter) if params else len(records or [])
return {"items": records or [], "total": total}
finally:
db.close()
@router.get("/workflows/{workflowId}")
async def _getWorkflow(
workflowId: str,
request: RequestContext = Depends(getRequestContext),
):
db = _getDb()
try:
db._ensureTableExists(AutoWorkflow)
wf = db.getRecord(AutoWorkflow, workflowId)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
_validateWorkflowAccess(request, wf, "read")
return wf
finally:
db.close()
@router.post("/workflows")
async def _createWorkflow(
request: RequestContext = Depends(getRequestContext),
body: Dict[str, Any] = {},
):
mandateId = body.get("mandateId")
if not mandateId:
raise HTTPException(status_code=400, detail="mandateId required")
_validateWorkflowAccess(request, {"mandateId": mandateId}, "write")
db = _getDb()
try:
db._ensureTableExists(AutoWorkflow)
import uuid
data = {**body, "id": str(uuid.uuid4())}
if request.user:
data.setdefault("runAsPrincipal", str(request.user.id))
rec = db.recordCreate(AutoWorkflow, data)
return rec
finally:
db.close()
@router.put("/workflows/{workflowId}")
async def _updateWorkflow(
workflowId: str,
request: RequestContext = Depends(getRequestContext),
body: Dict[str, Any] = {},
):
db = _getDb()
try:
db._ensureTableExists(AutoWorkflow)
wf = db.getRecord(AutoWorkflow, workflowId)
_validateWorkflowAccess(request, wf, "write")
updated = db.recordModify(AutoWorkflow, workflowId, body)
return updated
finally:
db.close()
@router.delete("/workflows/{workflowId}")
async def _deleteWorkflow(
workflowId: str,
request: RequestContext = Depends(getRequestContext),
):
db = _getDb()
try:
db._ensureTableExists(AutoWorkflow)
wf = db.getRecord(AutoWorkflow, workflowId)
_validateWorkflowAccess(request, wf, "delete")
for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId}) or []:
db.recordDelete(AutoVersion, v.get("id"))
for run in db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []:
runId = run.get("id")
for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
db.recordDelete(AutoStepLog, sl.get("id"))
db.recordDelete(AutoRun, runId)
for task in db.getRecordset(AutoTask, recordFilter={"workflowId": workflowId}) or []:
db.recordDelete(AutoTask, task.get("id"))
db.recordDelete(AutoWorkflow, workflowId)
return {"deleted": True, "workflowId": workflowId}
finally:
db.close()
# ---------------------------------------------------------------------------
# Runs
# ---------------------------------------------------------------------------
@router.get("/runs")
async def _listRuns(
request: RequestContext = Depends(getRequestContext),
pagination: Optional[str] = Query(default=None),
mandateId: Optional[str] = Query(default=None),
workflowId: Optional[str] = Query(default=None),
):
db = _getDb()
try:
db._ensureTableExists(AutoRun)
scopeFilter = _scopedRunFilter(request)
if mandateId:
if scopeFilter is None:
scopeFilter = {"mandateId": mandateId}
elif "mandateId" in scopeFilter:
if mandateId not in scopeFilter["mandateId"]:
return {"items": [], "total": 0}
scopeFilter = {"mandateId": mandateId}
if workflowId:
scopeFilter = {**(scopeFilter or {}), "workflowId": workflowId}
params = _parsePagination(pagination)
records = db.getRecordset(AutoRun, recordFilter=scopeFilter, pagination=params)
total = db.getRecordCount(AutoRun, recordFilter=scopeFilter) if params else len(records or [])
return {"items": records or [], "total": total}
finally:
db.close()
@router.get("/runs/{runId}")
async def _getRun(
runId: str,
request: RequestContext = Depends(getRequestContext),
):
db = _getDb()
try:
db._ensureTableExists(AutoRun)
run = db.getRecord(AutoRun, runId)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
wfId = run.get("workflowId")
if wfId:
wf = db.getRecord(AutoWorkflow, wfId)
_validateWorkflowAccess(request, wf, "read")
return run
finally:
db.close()
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@router.get("/tasks")
async def _listTasks(
request: RequestContext = Depends(getRequestContext),
pagination: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
):
db = _getDb()
try:
db._ensureTableExists(AutoTask)
scopeFilter: Optional[Dict[str, Any]] = None
if not request.isPlatformAdmin:
userId = str(request.user.id) if request.user else None
if not userId:
return {"items": [], "total": 0}
scopeFilter = {"assigneeId": userId}
if status:
scopeFilter = {**(scopeFilter or {}), "status": status}
params = _parsePagination(pagination)
records = db.getRecordset(AutoTask, recordFilter=scopeFilter, pagination=params)
total = db.getRecordCount(AutoTask, recordFilter=scopeFilter) if params else len(records or [])
return {"items": records or [], "total": total}
finally:
db.close()
# ---------------------------------------------------------------------------
# Versions
# ---------------------------------------------------------------------------
@router.get("/workflows/{workflowId}/versions")
async def _listVersions(
workflowId: str,
request: RequestContext = Depends(getRequestContext),
):
db = _getDb()
try:
db._ensureTableExists(AutoWorkflow)
wf = db.getRecord(AutoWorkflow, workflowId)
_validateWorkflowAccess(request, wf, "read")
db._ensureTableExists(AutoVersion)
versions = db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId})
return {"items": versions or []}
finally:
db.close()
# ---------------------------------------------------------------------------
# Step logs
# ---------------------------------------------------------------------------
@router.get("/runs/{runId}/steps")
async def _listStepLogs(
runId: str,
request: RequestContext = Depends(getRequestContext),
):
db = _getDb()
try:
db._ensureTableExists(AutoRun)
run = db.getRecord(AutoRun, runId)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
wfId = run.get("workflowId")
if wfId:
wf = db.getRecord(AutoWorkflow, wfId)
_validateWorkflowAccess(request, wf, "read")
db._ensureTableExists(AutoStepLog)
steps = db.getRecordset(AutoStepLog, recordFilter={"runId": runId})
return {"items": steps or []}
finally:
db.close()

View file

@ -27,10 +27,10 @@ from modules.interfaces.interfaceDbApp import getRootInterface
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( from modules.datamodels.datamodelWorkflowAutomation import (
AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion, AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion,
GRAPHICAL_EDITOR_DATABASE,
) )
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeWorkflowDashboard") routeApiMsg = apiRouteContext("routeWorkflowDashboard")
@ -44,7 +44,7 @@ router = APIRouter(prefix="/api/system/workflow-runs", tags=["WorkflowDashboard"
def _getDb() -> DatabaseConnector: def _getDb() -> DatabaseConnector:
return DatabaseConnector( return DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=graphicalEditorDatabase, dbDatabase=GRAPHICAL_EDITOR_DATABASE,
dbUser=APP_CONFIG.get("DB_USER"), dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
@ -619,7 +619,8 @@ def get_workflow_runs(
for wf in (wfs or []): for wf in (wfs or []):
wfMap[wf.get("id")] = wf wfMap[wf.get("id")] = wf
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels, resolveUserLabels
from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
runs = [] runs = []
for r in pageRuns: for r in pageRuns:
@ -635,17 +636,20 @@ def get_workflow_runs(
row["featureInstanceId"] = fiid row["featureInstanceId"] = fiid
runs.append(row) runs.append(row)
appDb = _getRootIface().db
enrichRowsWithFkLabels( enrichRowsWithFkLabels(
runs, runs,
db=db, db=db,
labelResolvers={ labelResolvers={
"mandateId": partial(resolveMandateLabels, db), "mandateId": partial(resolveMandateLabels, appDb),
"featureInstanceId": partial(resolveInstanceLabels, db), "featureInstanceId": partial(resolveInstanceLabels, appDb),
"ownerId": partial(resolveUserLabels, appDb),
}, },
) )
for row in runs: for row in runs:
row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) row["instanceLabel"] = row.pop("featureInstanceIdLabel", None)
row["mandateLabel"] = row.pop("mandateIdLabel", None) row["mandateLabel"] = row.pop("mandateIdLabel", None)
row["ownerLabel"] = row.pop("ownerIdLabel", None)
return {"runs": runs, "total": total, "limit": limit, "offset": offset} return {"runs": runs, "total": total, "limit": limit, "offset": offset}
@ -808,6 +812,9 @@ def get_system_workflows(
userMandateIds = _getUserMandateIds(userId) userMandateIds = _getUserMandateIds(userId)
adminMandateIds = _getAdminMandateIds(userId, userMandateIds) adminMandateIds = _getAdminMandateIds(userId, userMandateIds)
from modules.dbHelpers.fkLabelResolver import resolveUserLabels as _resolveUserLabels
from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
fkSortField = _firstFkSortFieldForWorkflows(paginationParams) fkSortField = _firstFkSortFieldForWorkflows(paginationParams)
if fkSortField: if fkSortField:
from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort
@ -869,17 +876,20 @@ def get_system_workflows(
row["canExecute"] = False row["canExecute"] = False
row.pop("graph", None) row.pop("graph", None)
items.append(row) items.append(row)
_appDb = _getRootIface().db
enrichRowsWithFkLabels( enrichRowsWithFkLabels(
items, items,
db=db, db=db,
labelResolvers={ labelResolvers={
"mandateId": partial(resolveMandateLabels, db), "mandateId": partial(resolveMandateLabels, _appDb),
"featureInstanceId": _resolveInstanceLabelsWithFeatureCode, "featureInstanceId": _resolveInstanceLabelsWithFeatureCode,
"ownerId": partial(_resolveUserLabels, _appDb),
}, },
) )
for row in items: for row in items:
row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) row["instanceLabel"] = row.pop("featureInstanceIdLabel", None)
row["mandateLabel"] = row.pop("mandateIdLabel", None) row["mandateLabel"] = row.pop("mandateIdLabel", None)
row["ownerLabel"] = row.pop("ownerIdLabel", None)
row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId")) row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId"))
if hasComputedFilter or hasComputedSort: if hasComputedFilter or hasComputedSort:
computedFilters = { computedFilters = {
@ -932,17 +942,20 @@ def get_system_workflows(
row["canExecute"] = False row["canExecute"] = False
row.pop("graph", None) row.pop("graph", None)
items.append(row) items.append(row)
_appDb2 = _getRootIface().db
enrichRowsWithFkLabels( enrichRowsWithFkLabels(
items, items,
db=db, db=db,
labelResolvers={ labelResolvers={
"mandateId": partial(resolveMandateLabels, db), "mandateId": partial(resolveMandateLabels, _appDb2),
"featureInstanceId": _resolveInstanceLabelsWithFeatureCode, "featureInstanceId": _resolveInstanceLabelsWithFeatureCode,
"ownerId": partial(_resolveUserLabels, _appDb2),
}, },
) )
for row in items: for row in items:
row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) row["instanceLabel"] = row.pop("featureInstanceIdLabel", None)
row["mandateLabel"] = row.pop("mandateIdLabel", None) row["mandateLabel"] = row.pop("mandateIdLabel", None)
row["ownerLabel"] = row.pop("ownerIdLabel", None)
row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId")) row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId"))
return { return {

View file

@ -89,6 +89,7 @@ def _resolveMandateId(context: Any) -> str:
def _getInterface(context: Any, instanceId: str): def _getInterface(context: Any, instanceId: str):
# DEPRECATED: will move with WorkflowAutomation code restructuring
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
return getGraphicalEditorInterface(_resolveUser(context), _resolveMandateId(context), instanceId) return getGraphicalEditorInterface(_resolveUser(context), _resolveMandateId(context), instanceId)
@ -306,6 +307,7 @@ async def _list_upstream_paths(params: Dict[str, Any], context: Any) -> ToolResu
return _err(name, f"Workflow {workflow_id} not found") return _err(name, f"Workflow {workflow_id} not found")
graph = wf.get("graph", {}) or {} graph = wf.get("graph", {}) or {}
# DEPRECATED: will move with WorkflowAutomation code restructuring
from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths
paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(node_id)) paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(node_id))
@ -436,6 +438,7 @@ async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolR
""" """
name = "listAvailableNodeTypes" name = "listAvailableNodeTypes"
try: try:
# DEPRECATED: will move with WorkflowAutomation code restructuring
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
nodeTypes = [] nodeTypes = []
for n in STATIC_NODE_TYPES: for n in STATIC_NODE_TYPES:
@ -462,6 +465,7 @@ async def _describeNodeType(params: Dict[str, Any], context: Any) -> ToolResult:
nodeType = params.get("nodeType") or params.get("id") nodeType = params.get("nodeType") or params.get("id")
if not nodeType: if not nodeType:
return _err(name, "nodeType required") return _err(name, "nodeType required")
# DEPRECATED: will move with WorkflowAutomation code restructuring
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
target: Dict[str, Any] = {} target: Dict[str, Any] = {}
for n in STATIC_NODE_TYPES: for n in STATIC_NODE_TYPES:
@ -875,6 +879,7 @@ async def _exportWorkflowToFile(params: Dict[str, Any], context: Any) -> ToolRes
envelope = iface.exportWorkflowToDict(workflowId) envelope = iface.exportWorkflowToDict(workflowId)
if envelope is None: if envelope is None:
return _err(name, f"Workflow {workflowId} not found") return _err(name, f"Workflow {workflowId} not found")
# DEPRECATED: will move with WorkflowAutomation code restructuring
from modules.features.graphicalEditor._workflowFileSchema import buildFileName from modules.features.graphicalEditor._workflowFileSchema import buildFileName
return _ok(name, { return _ok(name, {
"fileName": buildFileName(envelope.get("label", "workflow")), "fileName": buildFileName(envelope.get("label", "workflow")),

View file

@ -5,7 +5,10 @@ Document utility functions (Layer L0 - shared).
Pure text-processing helpers with zero internal dependencies. Pure text-processing helpers with zero internal dependencies.
""" """
import base64
import binascii
import re import re
from typing import Any, Optional
def parseInlineRuns(text: str) -> list: def parseInlineRuns(text: str) -> list:
@ -62,3 +65,49 @@ def parseInlineRuns(text: str) -> list:
runs.append({"type": "text", "value": text[lastEnd:]}) runs.append({"type": "text", "value": text[lastEnd:]})
return runs if runs else [{"type": "text", "value": text}] return runs if runs else [{"type": "text", "value": text}]
def _looksLikeAsciiBase64Payload(s: str) -> bool:
"""Heuristic: ActionDocument binary payloads use standard ASCII base64; markdown/text uses other chars."""
t = "".join(s.split())
if len(t) < 8:
return False
if not t.isascii():
return False
return bool(re.fullmatch(r"[A-Za-z0-9+/]+=*", t)) and len(t) % 4 == 0
def coerceDocumentDataToBytes(raw: Any) -> Optional[bytes]:
"""Normalize documentData for DB file persistence.
ActionDocument conventions (see methodFile.create): binary bodies are carried as ASCII
base64 strings; plain markdown/text stays as Unicode. Do not UTF-8-encode a base64
literal that persists the ASCII of the encoding (file looks like base64 gibberish).
"""
if raw is None:
return None
if isinstance(raw, bytes):
return raw if len(raw) > 0 else None
if isinstance(raw, bytearray):
b = bytes(raw)
return b if len(b) > 0 else None
if isinstance(raw, memoryview):
b = raw.tobytes()
return b if len(b) > 0 else None
if isinstance(raw, str):
stripped = raw.strip()
if not stripped:
return None
if _looksLikeAsciiBase64Payload(stripped):
try:
decoded = base64.b64decode(stripped, validate=True)
except (TypeError, binascii.Error, ValueError):
try:
decoded = base64.b64decode(stripped)
except (binascii.Error, ValueError):
decoded = b""
if decoded:
return decoded
b = stripped.encode("utf-8")
return b if len(b) > 0 else None
return None

View file

@ -242,6 +242,7 @@ def _registerNodeLabels():
added += 1 added += 1
try: try:
# DEPRECATED: will move with WorkflowAutomation code restructuring
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
for nd in STATIC_NODE_TYPES: for nd in STATIC_NODE_TYPES:
_reg(_extractRegistrySourceText(nd.get("label")), "node.label") _reg(_extractRegistrySourceText(nd.get("label")), "node.label")
@ -265,6 +266,7 @@ def _registerNodeLabels():
pass pass
try: try:
# DEPRECATED: will move with WorkflowAutomation code restructuring
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG
for schema in PORT_TYPE_CATALOG.values(): for schema in PORT_TYPE_CATALOG.values():
for field in getattr(schema, "fields", []) or []: for field in getattr(schema, "fields", []) or []:

View file

@ -429,6 +429,52 @@ async def _executeWithRetry(executor, node, context, maxRetries: int = 0, retryD
raise lastError raise lastError
def _validateFeatureInstanceMandates(graph: Dict[str, Any], mandateId: str) -> None:
"""Verify that all FeatureInstanceRef IDs in the graph belong to the workflow's mandate.
Logs a warning for each mismatch but does NOT abort execution the node
executor will fail on its own with a more specific error if the instance is
truly inaccessible. This is a defence-in-depth guard (A0.2).
"""
nodes = graph.get("nodes") if isinstance(graph, dict) else None
if not isinstance(nodes, list):
return
instanceIds: set = set()
for node in nodes:
if not isinstance(node, dict):
continue
params = node.get("parameters") or {}
ref = params.get("featureInstanceId")
if isinstance(ref, dict) and ref.get("$type") == "FeatureInstanceRef":
iid = ref.get("id")
if iid:
instanceIds.add(iid)
elif isinstance(ref, str) and ref.strip():
instanceIds.add(ref.strip())
if not instanceIds:
return
try:
from modules.interfaces.interfaceDbApp import getRootInterface
root = getRootInterface()
from modules.datamodels.datamodelFeatures import FeatureInstance
for iid in instanceIds:
fi = root.db.getRecord(FeatureInstance, iid)
if not fi:
logger.warning(
"MandateValidation: FeatureInstance %s referenced in graph not found", iid,
)
continue
fiMandateId = fi.get("mandateId") if isinstance(fi, dict) else getattr(fi, "mandateId", None)
if fiMandateId and fiMandateId != mandateId:
logger.warning(
"MandateValidation: FeatureInstance %s belongs to mandate %s, "
"but workflow mandate is %s — cross-mandate access",
iid, fiMandateId, mandateId,
)
except Exception as e:
logger.debug("MandateValidation: could not verify instances: %s", e)
def _substituteFeatureInstancePlaceholders( def _substituteFeatureInstancePlaceholders(
graph: Dict[str, Any], graph: Dict[str, Any],
targetFeatureInstanceId: str, targetFeatureInstanceId: str,
@ -675,6 +721,10 @@ async def executeGraph(
# Phase-5 Schicht-4: typed-ref envelopes are materialized FIRST so the # Phase-5 Schicht-4: typed-ref envelopes are materialized FIRST so the
# subsequent connection-ref pass and validation see the canonical shape. # subsequent connection-ref pass and validation see the canonical shape.
graph = materializeFeatureInstanceRefs(graph) graph = materializeFeatureInstanceRefs(graph)
if mandateId:
_validateFeatureInstanceMandates(graph, mandateId)
graph = materializeConnectionRefs(graph) graph = materializeConnectionRefs(graph)
graph = materializePrimaryTextHandover(graph) graph = materializePrimaryTextHandover(graph)
graph = materializeRecommendedDataPickRef(graph) graph = materializeRecommendedDataPickRef(graph)

View file

@ -7,8 +7,6 @@
# ``documentListWire`` is applied at runtime in this executor via graphUtils.extract_wired_document_list. # ``documentListWire`` is applied at runtime in this executor via graphUtils.extract_wired_document_list.
import base64
import binascii
import json import json
import logging import logging
import re import re
@ -122,50 +120,7 @@ def _log_file_create_context_resolution(
) )
def _looks_like_ascii_base64_payload(s: str) -> bool: from modules.shared.documentUtils import coerceDocumentDataToBytes # noqa: F401 — re-export shim
"""Heuristic: ActionDocument binary payloads use standard ASCII base64; markdown/text uses other chars (#, *, -, …)."""
t = "".join(s.split())
if len(t) < 8:
return False
if not t.isascii():
return False
return bool(re.fullmatch(r"[A-Za-z0-9+/]+=*", t)) and len(t) % 4 == 0
def coerceDocumentDataToBytes(raw: Any) -> Optional[bytes]:
"""Normalize documentData for DB file persistence.
ActionDocument conventions (see methodFile.create): binary bodies are carried as ASCII
base64 strings; plain markdown/text stays as Unicode. Do not UTF-8-encode a base64
literal that persists the ASCII of the encoding (file looks like base64 gibberish).
"""
if raw is None:
return None
if isinstance(raw, bytes):
return raw if len(raw) > 0 else None
if isinstance(raw, bytearray):
b = bytes(raw)
return b if len(b) > 0 else None
if isinstance(raw, memoryview):
b = raw.tobytes()
return b if len(b) > 0 else None
if isinstance(raw, str):
stripped = raw.strip()
if not stripped:
return None
if _looks_like_ascii_base64_payload(stripped):
try:
decoded = base64.b64decode(stripped, validate=True)
except (TypeError, binascii.Error, ValueError):
try:
decoded = base64.b64decode(stripped)
except (binascii.Error, ValueError):
decoded = b""
if decoded:
return decoded
b = stripped.encode("utf-8")
return b if len(b) > 0 else None
return None
def _image_documents_from_docs_list(docs_list: list) -> list: def _image_documents_from_docs_list(docs_list: list) -> list:

View file

@ -4,29 +4,11 @@
import logging import logging
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.serviceExceptions import PauseForHumanTaskError, PauseForEmailWaitError # noqa: F401 — re-export shim
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class PauseForHumanTaskError(Exception):
"""Raised when execution must pause for a human task. Contains runId, taskId."""
def __init__(self, runId: str, taskId: str, nodeId: str):
self.runId = runId
self.taskId = taskId
self.nodeId = nodeId
super().__init__(f"Pause for human task {taskId} (run {runId}, node {nodeId})")
class PauseForEmailWaitError(Exception):
"""Raised when execution must pause waiting for a new email. Background poller will resume."""
def __init__(self, runId: str, nodeId: str, waitConfig: Dict[str, Any]):
self.runId = runId
self.nodeId = nodeId
self.waitConfig = waitConfig
super().__init__(f"Pause for email wait (run {runId}, node {nodeId})")
class InputExecutor: class InputExecutor:
""" """
Execute input/human nodes. Creates a HumanTask, pauses the run, and raises Execute input/human nodes. Creates a HumanTask, pauses the run, and raises

View file

@ -22,7 +22,7 @@ import logging
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from modules.datamodels.datamodelChat import ActionResult from modules.datamodels.datamodelChat import ActionResult
from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError from modules.datamodels.serviceExceptions import PauseForHumanTaskError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -13,7 +13,7 @@ import re
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
from modules.workflows.automation2.executors.actionNodeExecutor import coerceDocumentDataToBytes from modules.shared.documentUtils import coerceDocumentDataToBytes
from modules.workflows.methods.methodAi._common import is_image_action_document_list from modules.workflows.methods.methodAi._common import is_image_action_document_list
from modules.workflows.methods.methodContext.actions.extractContent import ( from modules.workflows.methods.methodContext.actions.extractContent import (
presentation_envelopes_to_document_json, presentation_envelopes_to_document_json,

View file

@ -93,9 +93,9 @@ class WorkflowScheduler:
activeWorkflowIds.add(workflowId) activeWorkflowIds.add(workflowId)
cron = item.get("cron") cron = item.get("cron")
mandateId = item.get("mandateId") mandateId = item.get("mandateId")
instanceId = item.get("featureInstanceId") instanceId = item.get("featureInstanceId") or ""
if not instanceId or not cron: if not cron:
continue continue
jobId = f"{JOB_ID_PREFIX}{workflowId}" jobId = f"{JOB_ID_PREFIX}{workflowId}"