452 lines
16 KiB
Python
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))
|