# 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 ``.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}"