# Copyright (c) 2025 Patrick Motsch """ Workflow entry points (Starts) — configuration outside the flow editor. Kinds align with run envelope trigger.type where applicable. """ import uuid from typing import Any, Dict, List, Optional # On-demand (gear: Manueller Trigger, Formular) KINDS_ON_DEMAND = frozenset({"manual", "form", "api"}) # Always-on (gear: Zeitplan, Immer aktiv, plus legacy listener kinds) KINDS_ALWAYS_ON = frozenset({"schedule", "always_on", "email", "webhook", "event"}) ALL_KINDS = KINDS_ON_DEMAND | KINDS_ALWAYS_ON def category_for_kind(kind: str) -> str: if kind in KINDS_ALWAYS_ON: return "always_on" return "on_demand" def default_manual_entry_point() -> 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 _normalize_title(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 normalize_invocation_entry(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 = category_for_kind(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": _normalize_title(raw.get("title")), "description": desc, "config": config, } def normalize_invocations_list(items: Optional[List[Any]]) -> List[Dict[str, Any]]: if not items: return [default_manual_entry_point()] out: List[Dict[str, Any]] = [] for raw in items: if isinstance(raw, dict): out.append(normalize_invocation_entry(raw)) if not out: return [default_manual_entry_point()] return out _NODE_TYPE_TO_KIND = { "trigger.manual": "manual", "trigger.form": "form", "trigger.schedule": "schedule", } def invocations_synced_with_graph( graph: Optional[Dict[str, Any]], stored_invocations: 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. """ from modules.workflows.automation2.graphUtils import getTriggerNodes g = graph if isinstance(graph, dict) else {} nodes = g.get("nodes") or [] stored = list(stored_invocations or []) rest: List[Dict[str, Any]] = [] for raw in stored[1:]: if isinstance(raw, dict): rest.append(normalize_invocation_entry(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()) raw_title = node.get("title") or node.get("label") or "Start" old_primary = stored[0] if stored and isinstance(stored[0], dict) else {} config: Dict[str, Any] = {} if isinstance(old_primary.get("config"), dict) and old_primary.get("kind") == kind: config = dict(old_primary["config"]) desc = old_primary.get("description") if isinstance(old_primary.get("description"), dict) else {} primary_raw: Dict[str, Any] = { "id": str(nid), "kind": kind, "enabled": True, "title": raw_title, "description": desc, "config": config, } primary = normalize_invocation_entry(primary_raw) return [primary] + rest # POST .../execute with entryPointId set to a schedule entry — no separate in-process scheduler here yet. def find_invocation(workflow: Dict[str, Any], entry_point_id: str) -> Optional[Dict[str, Any]]: for inv in workflow.get("invocations") or []: if isinstance(inv, dict) and inv.get("id") == entry_point_id: return inv return None