157 lines
4.9 KiB
Python
157 lines
4.9 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
"""
|
|
Workflow entry points (Starts) — configuration outside the flow editor.
|
|
|
|
Kinds align with run envelope trigger.type where applicable.
|
|
|
|
Canonical location: modules.nodeCatalog.entryPoints (L2).
|
|
Depends only on stdlib — no cross-module imports.
|
|
"""
|
|
|
|
import uuid
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
KINDS_ON_DEMAND = frozenset({"manual", "form", "api"})
|
|
|
|
KINDS_ALWAYS_ON = frozenset({"schedule", "always_on", "email", "webhook", "event"})
|
|
|
|
ALL_KINDS = KINDS_ON_DEMAND | KINDS_ALWAYS_ON
|
|
|
|
|
|
def categoryForKind(kind: str) -> str:
|
|
if kind in KINDS_ALWAYS_ON:
|
|
return "always_on"
|
|
return "on_demand"
|
|
|
|
|
|
def defaultManualEntryPoint() -> Dict[str, Any]:
|
|
"""Single default manual start when a workflow has no invocations yet."""
|
|
return {
|
|
"id": str(uuid.uuid4()),
|
|
"kind": "manual",
|
|
"category": "on_demand",
|
|
"enabled": True,
|
|
"title": "Jetzt ausführen",
|
|
"description": {},
|
|
"config": {},
|
|
}
|
|
|
|
|
|
def _normalizeTitle(title: Any) -> str:
|
|
"""Extract a plain string from a title value for storage (not display)."""
|
|
if isinstance(title, dict):
|
|
picked = title.get("xx") or next((v for v in title.values() if v), None)
|
|
return str(picked).strip() if picked else "Start"
|
|
if isinstance(title, str) and title.strip():
|
|
return title.strip()
|
|
return "Start"
|
|
|
|
|
|
def normalizeInvocationEntry(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Validate and normalize a single entry point dict."""
|
|
kind = (raw.get("kind") or "manual").strip()
|
|
if kind not in ALL_KINDS:
|
|
kind = "manual"
|
|
cat = raw.get("category")
|
|
if cat not in ("on_demand", "always_on"):
|
|
cat = categoryForKind(kind)
|
|
eid = raw.get("id") or str(uuid.uuid4())
|
|
enabled = raw.get("enabled", True)
|
|
if not isinstance(enabled, bool):
|
|
enabled = bool(enabled)
|
|
config = raw.get("config") if isinstance(raw.get("config"), dict) else {}
|
|
desc = raw.get("description") if isinstance(raw.get("description"), dict) else {}
|
|
return {
|
|
"id": str(eid),
|
|
"kind": kind,
|
|
"category": cat,
|
|
"enabled": enabled,
|
|
"title": _normalizeTitle(raw.get("title")),
|
|
"description": desc,
|
|
"config": config,
|
|
}
|
|
|
|
|
|
def normalizeInvocationsList(items: Optional[List[Any]]) -> List[Dict[str, Any]]:
|
|
if not items:
|
|
return [defaultManualEntryPoint()]
|
|
out: List[Dict[str, Any]] = []
|
|
for raw in items:
|
|
if isinstance(raw, dict):
|
|
out.append(normalizeInvocationEntry(raw))
|
|
if not out:
|
|
return [defaultManualEntryPoint()]
|
|
return out
|
|
|
|
|
|
_NODE_TYPE_TO_KIND = {
|
|
"trigger.manual": "manual",
|
|
"trigger.form": "form",
|
|
"trigger.schedule": "schedule",
|
|
}
|
|
|
|
|
|
def _getTriggerNodes(nodes: List[Dict]) -> List[Dict]:
|
|
"""Return start/trigger nodes: type ``trigger.*``, or category ``trigger`` / ``start``."""
|
|
return [
|
|
n
|
|
for n in nodes
|
|
if (
|
|
str(n.get("type", "")).startswith("trigger.")
|
|
or n.get("category") in ("trigger", "start")
|
|
)
|
|
]
|
|
|
|
|
|
def invocationsSyncedWithGraph(
|
|
graph: Optional[Dict[str, Any]],
|
|
storedInvocations: Optional[List[Any]],
|
|
) -> List[Dict[str, Any]]:
|
|
"""Derive primary invocation (index 0) from the first start node in ``graph``.
|
|
|
|
If the graph has no start node, only non-primary stored invocations are kept
|
|
(no injected default). Document order in ``nodes`` defines which start wins.
|
|
"""
|
|
g = graph if isinstance(graph, dict) else {}
|
|
nodes = g.get("nodes") or []
|
|
stored = list(storedInvocations or [])
|
|
rest: List[Dict[str, Any]] = []
|
|
for raw in stored[1:]:
|
|
if isinstance(raw, dict):
|
|
rest.append(normalizeInvocationEntry(raw))
|
|
|
|
triggers = _getTriggerNodes(nodes)
|
|
if not triggers:
|
|
return rest
|
|
|
|
node = triggers[0]
|
|
nt = str(node.get("type", "")).strip()
|
|
kind = _NODE_TYPE_TO_KIND.get(nt, "manual")
|
|
nid = node.get("id")
|
|
if not nid:
|
|
nid = str(uuid.uuid4())
|
|
rawTitle = node.get("title") or node.get("label") or "Start"
|
|
|
|
oldPrimary = stored[0] if stored and isinstance(stored[0], dict) else {}
|
|
config: Dict[str, Any] = {}
|
|
if isinstance(oldPrimary.get("config"), dict) and oldPrimary.get("kind") == kind:
|
|
config = dict(oldPrimary["config"])
|
|
desc = oldPrimary.get("description") if isinstance(oldPrimary.get("description"), dict) else {}
|
|
|
|
primaryRaw: Dict[str, Any] = {
|
|
"id": str(nid),
|
|
"kind": kind,
|
|
"enabled": True,
|
|
"title": rawTitle,
|
|
"description": desc,
|
|
"config": config,
|
|
}
|
|
primary = normalizeInvocationEntry(primaryRaw)
|
|
return [primary] + rest
|
|
|
|
|
|
def findInvocation(workflow: Dict[str, Any], entryPointId: str) -> Optional[Dict[str, Any]]:
|
|
for inv in workflow.get("invocations") or []:
|
|
if isinstance(inv, dict) and inv.get("id") == entryPointId:
|
|
return inv
|
|
return None
|