gateway/modules/workflows/methods/methodContext/actions/setContext.py

452 lines
16 KiB
Python

# 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))