gateway/modules/features/graphicalEditor/_workflowFileSchema.py
2026-04-20 00:31:05 +02:00

284 lines
9 KiB
Python

# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""
Workflow File Schema (Versioned Envelope) for the GraphicalEditor.
A *workflow file* is a portable JSON representation of an ``AutoWorkflow`` that
can be exchanged between mandates / instances / installations. It contains the
graph, the entry-points (invocations), and a small set of metadata under the
``$``-prefixed envelope keys.
Persistence-bound fields (``id``, ``mandateId``, ``featureInstanceId``,
``currentVersionId``, ``eventId``, ``active``, ``sysCreated*``,
``sysModified*``) are NEVER part of the file — they are stripped on export and
re-derived on import.
Reference: ``wiki/c-work/1-plan/2026-04-pwg-pilot-mietzinsbestaetigung-workflow.md``
"""
from typing import Any, Dict, List, Optional, Tuple
import logging
import time
logger = logging.getLogger(__name__)
WORKFLOW_FILE_SCHEMA_VERSION = "1.0"
WORKFLOW_FILE_KIND = "poweron.workflow"
WORKFLOW_FILE_EXTENSION = ".workflow.json"
_PERSISTENCE_FIELDS = (
"id",
"mandateId",
"featureInstanceId",
"currentVersionId",
"eventId",
"active",
"templateSourceId",
"sysCreatedBy",
"sysCreatedAt",
"sysModifiedBy",
"sysModifiedAt",
)
_ENVELOPE_KEYS = (
"$schemaVersion",
"$kind",
"$exportedAt",
"$gatewayVersion",
)
_PORTABLE_WORKFLOW_FIELDS = (
"label",
"description",
"tags",
"templateScope",
"sharedReadOnly",
"notifyOnFailure",
"isTemplate",
"graph",
"invocations",
)
class WorkflowFileSchemaError(ValueError):
"""Raised when a workflow file does not conform to the expected schema."""
def isWorkflowFileEnvelope(payload: Any) -> bool:
"""Quick content-sniff used by the UDB to decide whether a file is a
workflow envelope (without raising on malformed input)."""
if not isinstance(payload, dict):
return False
if payload.get("$kind") == WORKFLOW_FILE_KIND:
return True
if "$schemaVersion" in payload and isinstance(payload.get("graph"), dict):
return True
return False
def _normalizeNodePosition(node: Dict[str, Any]) -> Dict[str, Any]:
"""Canonicalize node coordinates to top-level ``x`` / ``y``.
The canvas uses top-level ``x`` / ``y``; the agent ``addNode`` tool also
accepts ``position={x, y}``. Files may use either (or both) shape — pick
whatever is present and persist the canonical form.
"""
if not isinstance(node, dict):
return node
out = dict(node)
pos = out.pop("position", None)
x = out.get("x")
y = out.get("y")
if x is None and isinstance(pos, dict):
x = pos.get("x")
if y is None and isinstance(pos, dict):
y = pos.get("y")
if x is None:
x = 0
if y is None:
y = 0
out["x"] = x
out["y"] = y
return out
def normalizeGraph(graph: Any) -> Dict[str, Any]:
"""Return a graph dict with ``nodes`` and ``connections`` lists, node
coordinates normalized to top-level ``x`` / ``y``."""
if not isinstance(graph, dict):
return {"nodes": [], "connections": []}
nodes = graph.get("nodes") or []
connections = graph.get("connections") or []
if not isinstance(nodes, list):
nodes = []
if not isinstance(connections, list):
connections = []
return {
"nodes": [_normalizeNodePosition(n) for n in nodes if isinstance(n, dict)],
"connections": [c for c in connections if isinstance(c, dict)],
}
def _stripPersistenceFields(workflowDict: Dict[str, Any]) -> Dict[str, Any]:
"""Return a copy of *workflowDict* with all persistence-bound fields removed."""
out = {}
for k, v in workflowDict.items():
if k in _PERSISTENCE_FIELDS:
continue
out[k] = v
return out
def buildFileFromWorkflow(
workflowDict: Dict[str, Any],
gatewayVersion: Optional[str] = None,
) -> Dict[str, Any]:
"""Build a portable workflow-file envelope from an ``AutoWorkflow`` row.
Strips persistence-bound fields, normalizes the graph, and prepends the
``$``-envelope keys.
"""
if not isinstance(workflowDict, dict):
raise WorkflowFileSchemaError("workflowDict must be a dict")
body: Dict[str, Any] = {}
body["$schemaVersion"] = WORKFLOW_FILE_SCHEMA_VERSION
body["$kind"] = WORKFLOW_FILE_KIND
body["$exportedAt"] = _isoTimestamp()
if gatewayVersion:
body["$gatewayVersion"] = str(gatewayVersion)
stripped = _stripPersistenceFields(workflowDict)
for field in _PORTABLE_WORKFLOW_FIELDS:
if field in stripped:
value = stripped[field]
if field == "graph":
value = normalizeGraph(value)
body[field] = value
return body
def validateFileEnvelope(
payload: Any,
knownNodeTypes: Optional[List[str]] = None,
) -> Tuple[Dict[str, Any], List[str]]:
"""Validate a workflow-file envelope.
Returns ``(normalizedEnvelope, warnings)``. Raises
``WorkflowFileSchemaError`` on hard errors (unknown schema version,
missing graph, unknown node types).
"""
if not isinstance(payload, dict):
raise WorkflowFileSchemaError("Workflow file must be a JSON object")
schemaVersion = payload.get("$schemaVersion")
if not schemaVersion:
raise WorkflowFileSchemaError(
"Missing $schemaVersion — file is not a recognized workflow file"
)
if schemaVersion != WORKFLOW_FILE_SCHEMA_VERSION:
raise WorkflowFileSchemaError(
f"Unsupported $schemaVersion '{schemaVersion}' "
f"(this gateway supports '{WORKFLOW_FILE_SCHEMA_VERSION}')"
)
kind = payload.get("$kind")
if kind and kind != WORKFLOW_FILE_KIND:
raise WorkflowFileSchemaError(
f"Unexpected $kind '{kind}' (expected '{WORKFLOW_FILE_KIND}')"
)
label = payload.get("label")
if not isinstance(label, str) or not label.strip():
raise WorkflowFileSchemaError("Field 'label' is required and must be a non-empty string")
graph = payload.get("graph")
if not isinstance(graph, dict):
raise WorkflowFileSchemaError("Field 'graph' is required and must be an object")
normalizedGraph = normalizeGraph(graph)
warnings: List[str] = []
if not normalizedGraph["nodes"]:
warnings.append("Workflow has no nodes")
if knownNodeTypes is not None:
knownSet = set(knownNodeTypes)
unknownTypes = []
for node in normalizedGraph["nodes"]:
nodeType = node.get("type")
if nodeType and nodeType not in knownSet:
unknownTypes.append(nodeType)
if unknownTypes:
uniqueUnknown = sorted(set(unknownTypes))
raise WorkflowFileSchemaError(
"Workflow file references unknown node type(s) not registered in this gateway: "
+ ", ".join(uniqueUnknown)
)
nodeIds = {n.get("id") for n in normalizedGraph["nodes"] if n.get("id")}
for c in normalizedGraph["connections"]:
src = c.get("source")
tgt = c.get("target")
if src and src not in nodeIds:
warnings.append(f"Connection source '{src}' is not a known node id")
if tgt and tgt not in nodeIds:
warnings.append(f"Connection target '{tgt}' is not a known node id")
out: Dict[str, Any] = {}
for k in _ENVELOPE_KEYS:
if k in payload:
out[k] = payload[k]
for field in _PORTABLE_WORKFLOW_FIELDS:
if field in payload:
out[field] = payload[field]
out["graph"] = normalizedGraph
return out, warnings
def envelopeToWorkflowData(
envelope: Dict[str, Any],
mandateId: str,
featureInstanceId: str,
) -> Dict[str, Any]:
"""Convert a validated workflow-file envelope into a dict suitable for
``GraphicalEditorObjects.createWorkflow`` / ``updateWorkflow``.
Imports are always inactive — operators must explicitly activate them.
Persistence-bound fields are NEVER copied from the envelope.
"""
data: Dict[str, Any] = {
"mandateId": mandateId,
"featureInstanceId": featureInstanceId,
"active": False,
}
for field in _PORTABLE_WORKFLOW_FIELDS:
if field in envelope:
data[field] = envelope[field]
if "label" not in data or not data["label"]:
data["label"] = "Imported Workflow"
if "graph" in data:
data["graph"] = normalizeGraph(data["graph"])
return data
def _isoTimestamp() -> str:
"""UTC timestamp in ISO 8601 format (used for ``$exportedAt``)."""
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def buildFileName(label: str) -> str:
"""Build a safe filename ``<slug>.workflow.json`` from a workflow label."""
base = (label or "workflow").strip().lower()
safe_chars = []
for ch in base:
if ch.isalnum() or ch in ("-", "_"):
safe_chars.append(ch)
elif ch in (" ", ":", "/", "\\", "."):
safe_chars.append("-")
slug = "".join(safe_chars).strip("-") or "workflow"
while "--" in slug:
slug = slug.replace("--", "-")
return f"{slug[:80]}{WORKFLOW_FILE_EXTENSION}"