284 lines
9 KiB
Python
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}"
|