# Copyright (c) 2026 PowerOn AG # All rights reserved. """ 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