# Copyright (c) 2026 Patrick Motsch # All rights reserved. """Action ``context.setContext``. Stores values in the workflow context (``local`` | ``global`` | ``session``). Each **assignment** row defines a target ``contextKey`` and how to obtain the value: - ``valueSource=pickUpstream`` — use ``upstreamRef`` (DataRef resolved by the graph) or, for experts, a dotted ``sourcePath`` on ``_upstreamPayload``. - ``valueSource=literal`` — use ``literal`` (with ``valueType`` coercion). - ``valueSource=humanTask`` — pause and create a task (requires ``_automation2Interface``). Legacy graphs may still send ``entries`` / ``upstreamPick`` + ``targetKey``; those are normalized into the same shape before processing. """ from __future__ import annotations import json import logging from typing import Any, Dict, List, Optional, Tuple from modules.datamodels.datamodelChat import ActionResult from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError logger = logging.getLogger(__name__) _VALID_MODES = {"set", "setIfEmpty", "append", "increment"} _VALID_SCOPES = {"local", "global", "session"} _VALID_VALUE_SOURCES = {"pickUpstream", "literal", "humanTask"} def _get_by_path(data: Any, dotted: str) -> Any: """Traverse dict/list by dotted path (``payload.status``, ``items.0.name``).""" if not dotted or not str(dotted).strip(): return None cur: Any = data for seg in str(dotted).strip().split("."): if cur is None: return None if isinstance(cur, dict) and seg in cur: cur = cur[seg] continue if isinstance(cur, (list, tuple)): try: idx = int(seg) except ValueError: return None if 0 <= idx < len(cur): cur = cur[idx] continue return None return cur def _is_unresolved_ref(value: Any) -> bool: return isinstance(value, dict) and value.get("type") == "ref" def _coerce_type(value: Any, type_str: str) -> Any: """Best-effort coerce ``value`` into the declared entry ``type``.""" if type_str in (None, "", "any", "Any"): return value try: if type_str == "str": return "" if value is None else str(value) if type_str == "int": if isinstance(value, bool): return int(value) if value is None or value == "": return 0 return int(float(value)) if type_str == "float": if value is None or value == "": return 0.0 return float(value) if type_str == "bool": if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) return str(value).strip().lower() in ("1", "true", "yes", "on", "ja") if type_str in ("list", "List", "array"): if value is None: return [] if isinstance(value, str) and value.strip().startswith(("[", "{")): try: parsed = json.loads(value) return parsed if isinstance(parsed, list) else [parsed] except json.JSONDecodeError: pass return value if isinstance(value, list) else [value] if type_str in ("object", "dict", "Dict"): if isinstance(value, str) and value.strip().startswith("{"): try: parsed = json.loads(value) return parsed if isinstance(value, dict) else {"value": parsed} except json.JSONDecodeError: pass return value if isinstance(value, dict) else {"value": value} except (TypeError, ValueError) as exc: logger.warning("setContext._coerce_type %r → %s failed: %s", value, type_str, exc) return value def _resolve_store(scope: str, run_context: Optional[Dict[str, Any]]) -> Dict[str, Any]: """Return the dict that backs the requested scope.""" if not isinstance(run_context, dict): return {} if scope == "global": return run_context.setdefault("_globalContext", {}) if scope == "session": return run_context.setdefault("_sessionContext", {}) return run_context.setdefault("_localContext", {}) def _entry_context_key(entry: Dict[str, Any]) -> Optional[str]: ck = entry.get("contextKey") or entry.get("key") if ck is None: return None s = str(ck).strip() return s or None def _apply_value_to_store( store: Dict[str, Any], context_key: str, value: Any, mode: str, type_str: str, ) -> Optional[str]: """Apply coerced ``value`` to ``store[context_key]``. Returns error string or None.""" if mode not in _VALID_MODES: return f"unknown mode '{mode}' on key '{context_key}'" coerced = _coerce_type(value, str(type_str or "")) if mode == "set": store[context_key] = coerced return None if mode == "setIfEmpty": if context_key not in store or store.get(context_key) in (None, "", [], {}): store[context_key] = coerced return None if mode == "append": existing = store.get(context_key) if existing is None: store[context_key] = [coerced] if not isinstance(coerced, list) else list(coerced) elif isinstance(existing, list): if isinstance(coerced, list): existing.extend(coerced) else: existing.append(coerced) elif isinstance(existing, str): store[context_key] = existing + ("" if coerced is None else str(coerced)) else: store[context_key] = [existing, coerced] return None if mode == "increment": existing = store.get(context_key, 0) try: store[context_key] = ( float(existing) + float(coerced) if isinstance(existing, float) or isinstance(coerced, float) else int(existing) + int(coerced) ) except (TypeError, ValueError): return f"increment requires numeric value/state for key '{context_key}'" return None return None def _value_source(row: Dict[str, Any]) -> str: vs = row.get("valueSource") if isinstance(vs, str) and vs.strip() in _VALID_VALUE_SOURCES: return vs.strip() am = str(row.get("assignmentMode") or "direct").strip() if am == "fromUpstream": return "pickUpstream" if am == "humanTask": return "humanTask" if am == "direct": return "literal" return "literal" def _normalize_assignments(parameters: Dict[str, Any]) -> List[Dict[str, Any]]: """Build a single list of assignment dicts from new or legacy parameters.""" raw = parameters.get("assignments") if isinstance(raw, list) and raw: out: List[Dict[str, Any]] = [] for item in raw: if isinstance(item, dict): out.append(dict(item)) if out: return out legacy_entries = parameters.get("entries") global_pick = parameters.get("upstreamPick") if isinstance(legacy_entries, list) and legacy_entries: out = [] for entry in legacy_entries: if not isinstance(entry, dict): continue row = dict(entry) row["valueSource"] = _value_source(entry) am = str(entry.get("assignmentMode") or "direct").strip() if am == "fromUpstream" and not str(entry.get("sourcePath") or "").strip(): if global_pick is not None and not (isinstance(global_pick, str) and not global_pick.strip()): if not (isinstance(global_pick, (list, dict)) and len(global_pick) == 0): row["upstreamRef"] = global_pick if am == "direct": row["literal"] = entry.get("value") row["valueSource"] = "literal" out.append(row) if out: return out tk = str(parameters.get("targetKey") or "").strip() if tk and global_pick is not None: if isinstance(global_pick, str) and not global_pick.strip(): pass elif isinstance(global_pick, (list, dict)) and len(global_pick) == 0: pass else: return [ { "contextKey": tk, "valueSource": "pickUpstream", "upstreamRef": global_pick, "mode": "set", "valueType": "str", } ] return [] def _resolve_pick_upstream( row: Dict[str, Any], upstream: Any, parameters: Dict[str, Any], ) -> Tuple[Optional[Any], Optional[str]]: path = str(row.get("sourcePath") or "").strip() ref_val = row.get("upstreamRef") if ref_val is not None and ref_val != "": if _is_unresolved_ref(ref_val): return None, "upstream DataRef konnte nicht aufgelöst werden" base: Any = ref_val if path: hit = _get_by_path(base, path) if hit is None and isinstance(upstream, dict): hit = _get_by_path(upstream, path) if hit is not None: return hit, None return None, f"path '{path}' not found under picked value or upstream payload" return base, None if path: if not isinstance(upstream, dict): return None, "sourcePath benötigt ein strukturiertes Upstream-Payload (dict)" return _get_by_path(upstream, path), None return None, "Picker: Datenquelle wählen oder sourcePath (z. B. payload.status) setzen" def _resolve_literal(row: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]: raw = row.get("literal") if raw is None and "value" in row: raw = row.get("value") if raw is None: return None, "literal value missing" if isinstance(raw, (dict, list, bool, int, float)) or raw is None: return raw, None s = str(raw) type_str = str(row.get("valueType") or row.get("type") or "str") if type_str in ("object", "dict", "Dict", "list", "List", "array") and s.strip().startswith(("[", "{")): try: return json.loads(s), None except json.JSONDecodeError as exc: return None, f"invalid JSON literal: {exc}" return s, None def _pause_for_human_tasks( *, iface: Any, run_context: Dict[str, Any], parameters: Dict[str, Any], pending_entries: List[Dict[str, Any]], scope: str, ) -> None: """Create a single human task for all ``humanTask`` rows and pause the run.""" run_id = str(run_context.get("_runId") or "") workflow_id = str(run_context.get("workflowId") or "") node_id = str(parameters.get("_workflowNodeId") or "") user_id = run_context.get("userId") cfg = { "kind": "contextSetAssignment", "scope": scope, "entries": pending_entries, "description": ( "Set or confirm workflow context keys. After completion, resume the run;" " submitted values should be merged into context by the task handler." ), } task = iface.createTask( runId=run_id, workflowId=workflow_id, nodeId=node_id, nodeType="context.setContext", config=cfg, assigneeId=str(user_id) if user_id else None, ) task_id = str((task or {}).get("id") or "") ordered_ids = [n.get("id") for n in (run_context.get("_orderedNodes") or []) if n.get("id")] iface.updateRun( run_id, status="paused", nodeOutputs=run_context.get("nodeOutputs"), currentNodeId=node_id, context={ "connectionMap": run_context.get("connectionMap"), "inputSources": run_context.get("inputSources"), "orderedNodeIds": ordered_ids, "pauseReason": "contextAssignment", }, ) if not (run_id and task_id and node_id): raise RuntimeError("humanTask requires _runId, task id, and _workflowNodeId") raise PauseForHumanTaskError(runId=run_id, taskId=task_id, nodeId=node_id) async def setContext(self, parameters: Dict[str, Any]) -> ActionResult: try: scope = str(parameters.get("scope") or "local") if scope not in _VALID_SCOPES: return ActionResult.isFailure(error=f"Invalid scope '{scope}', expected one of {sorted(_VALID_SCOPES)}") entries: List[Dict[str, Any]] = _normalize_assignments(parameters) if not entries: return ActionResult.isFailure( error="Mindestens eine Zuweisung konfigurieren (Ziel-Schlüssel, Quelle und Wert / Picker / Task).", ) run_context = parameters.get("_runContext") if not isinstance(run_context, dict): return ActionResult.isFailure(error="internal: execution context missing") store = _resolve_store(scope, run_context) upstream = parameters.get("_upstreamPayload") applied: Dict[str, Any] = {} errors: List[str] = [] human_rows: List[Dict[str, Any]] = [] for entry in entries: if not isinstance(entry, dict): errors.append("entry is not an object") continue ck = _entry_context_key(entry) if not ck: errors.append("assignment needs contextKey") continue vs = _value_source(entry) if vs not in _VALID_VALUE_SOURCES: errors.append(f"{ck}: unknown valueSource '{vs}'") continue if vs == "humanTask": human_rows.append( { "contextKey": ck, "sourcePath": entry.get("sourcePath"), "taskTitle": entry.get("taskTitle"), "taskDescription": entry.get("taskDescription"), "type": entry.get("valueType") or entry.get("type"), "mode": entry.get("mode") or "set", } ) continue val: Any = None err: Optional[str] = None if vs == "pickUpstream": val, err = _resolve_pick_upstream(entry, upstream, parameters) else: val, err = _resolve_literal(entry) if err: errors.append(f"{ck}: {err}") continue err2 = _apply_value_to_store( store, ck, val, str(entry.get("mode") or "set"), str(entry.get("valueType") or entry.get("type") or ""), ) if err2: errors.append(f"{ck}: {err2}") continue applied[ck] = store.get(ck) iface = run_context.get("_automation2Interface") if human_rows: if iface: _pause_for_human_tasks( iface=iface, run_context=run_context, parameters=parameters, pending_entries=human_rows, scope=scope, ) else: applied["_humanTaskFallback"] = ( "humanTask requires a live automation2 interface on the run; " "configure execution via the graphical editor API or add an input.human node." ) applied["_pendingHumanContextKeys"] = [r["contextKey"] for r in human_rows] if errors and not applied and not human_rows: return ActionResult.isFailure(error="; ".join(errors)) data: Dict[str, Any] = dict(applied) data["_scope"] = scope data["_appliedKeys"] = [k for k in applied if not str(k).startswith("_")] if errors: data["_warnings"] = errors if isinstance(upstream, dict): meta = upstream.get("_meta") if isinstance(meta, dict): data["_meta"] = meta data.setdefault("_transit", True) return ActionResult.isSuccess(data=data) except PauseForHumanTaskError: raise except Exception as exc: logger.exception("setContext failed") return ActionResult.isFailure(error=str(exc))