diff --git a/py/semoss_automation_runtime.py b/py/semoss_automation_runtime.py
index 4eda8ca24f..9c34f43d6c 100644
--- a/py/semoss_automation_runtime.py
+++ b/py/semoss_automation_runtime.py
@@ -10,10 +10,16 @@
import base64
import json
import re
+import xml.etree.ElementTree as ET
+from collections.abc import Callable
from typing import Any
_PLACEHOLDER_PATTERN = re.compile(r"\$\{([^}]+)\}")
+_DATA_PATH_TOKEN_PATTERN = re.compile(
+ r"([^.\[\]]+)|\[(\d+|\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*')\]"
+)
+_MISSING = object()
class AutomationScope(dict[str, Any]):
@@ -99,6 +105,162 @@ def resolve_config(self, value: Any) -> Any:
return self.resolve(value)
+def extract_data_element(
+ source: Any,
+ path: str,
+ source_format: str = "auto",
+ missing_value: Any = None,
+ null_value: Any = None,
+ asset_reader: Callable[[str], str] | None = None,
+) -> Any:
+ """Return one nested JSON/XML value with distinct missing and null fallbacks."""
+ data = _structured_data(source, source_format, asset_reader)
+ value = _nested_value(data, _data_path_tokens(path))
+ if value is _MISSING:
+ return missing_value
+ if value is None:
+ return null_value
+ return value
+
+
+def _structured_data(
+ source: Any,
+ source_format: str,
+ asset_reader: Callable[[str], str] | None,
+) -> Any:
+ normalized_format = source_format.strip().lower()
+ if normalized_format not in {"auto", "json", "xml"}:
+ raise ValueError("Data format must be auto, json, or xml.")
+ if isinstance(source, (dict, list)):
+ if normalized_format == "xml":
+ raise ValueError("XML input must be text or a run-workspace file path.")
+ return source
+ if not isinstance(source, str) or not source.strip():
+ raise ValueError("Data must be a JSON/XML value, encoded content, or file path.")
+
+ candidates = [source]
+ decoded = _decoded_text(source)
+ if decoded is not None:
+ candidates.append(decoded)
+
+ errors: list[Exception] = []
+ for candidate in candidates:
+ try:
+ return _parse_structured_text(candidate, normalized_format)
+ except (json.JSONDecodeError, ET.ParseError, UnicodeError, ValueError) as error:
+ errors.append(error)
+ if asset_reader is not None:
+ try:
+ return _parse_structured_text(asset_reader(source), normalized_format)
+ except (json.JSONDecodeError, ET.ParseError, UnicodeError, ValueError) as error:
+ errors.append(error)
+ raise ValueError(
+ "Unable to read JSON or XML from the supplied value or run-workspace file."
+ ) from errors[-1]
+
+
+def _parse_structured_text(value: str, source_format: str) -> Any:
+ text = value.lstrip("\ufeff\r\n\t ")
+ if source_format == "json" or source_format == "auto" and text[:1] in "[{":
+ return json.loads(text)
+ if source_format == "xml" or source_format == "auto" and text.startswith("<"):
+ root = ET.fromstring(text)
+ return {root.tag: _xml_value(root)}
+ if source_format == "auto":
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ root = ET.fromstring(text)
+ return {root.tag: _xml_value(root)}
+ raise ValueError(f"Input is not valid {source_format.upper()}.")
+
+
+def _decoded_text(value: str) -> str | None:
+ try:
+ return base64.b64decode(value, validate=True).decode("utf-8-sig")
+ except (ValueError, UnicodeError):
+ return None
+
+
+def _xml_value(element: ET.Element) -> Any:
+ children = list(element)
+ attributes: dict[str, Any] = {
+ f"@{name}": value for name, value in element.attrib.items()
+ }
+ text = (element.text or "").strip()
+ if not children:
+ if not attributes:
+ return text or None
+ if text:
+ attributes["#text"] = text
+ return attributes
+
+ result = attributes
+ for child in children:
+ value = _xml_value(child)
+ existing = result.get(child.tag, _MISSING)
+ if existing is _MISSING:
+ result[child.tag] = value
+ elif isinstance(existing, list):
+ existing.append(value)
+ else:
+ result[child.tag] = [existing, value]
+ if text:
+ result["#text"] = text
+ return result
+
+
+def _data_path_tokens(path: str) -> list[str | int]:
+ value = path.strip()
+ if not value:
+ raise ValueError("Value path cannot be blank.")
+ if value.startswith("/"):
+ return [
+ int(token) if token.isdigit() else token.replace("~1", "/").replace("~0", "~")
+ for token in value.split("/")[1:]
+ ]
+ if value == "$":
+ return []
+ if value.startswith("$."):
+ value = value[2:]
+ elif value.startswith("$["):
+ value = value[1:]
+ elif value.startswith("$"):
+ raise ValueError("Value path must use '$.' or '$[' after the root marker.")
+
+ tokens: list[str | int] = []
+ cursor = 0
+ for match in _DATA_PATH_TOKEN_PATTERN.finditer(value):
+ separator = value[cursor : match.start()]
+ if separator not in {"", "."}:
+ raise ValueError(f"Invalid value path near '{separator}'.")
+ raw = match.group(1) or match.group(2)
+ if raw is None:
+ continue
+ if raw.isdigit():
+ tokens.append(int(raw))
+ elif raw[:1] in {"\"", "'"}:
+ tokens.append(json.loads(raw) if raw.startswith("\"") else raw[1:-1])
+ else:
+ tokens.append(raw)
+ cursor = match.end()
+ if cursor != len(value) or not tokens:
+ raise ValueError("Value path is invalid.")
+ return tokens
+
+
+def _nested_value(value: Any, tokens: list[str | int]) -> Any:
+ current = value
+ for token in tokens:
+ if isinstance(current, dict) and token in current:
+ current = current[token]
+ elif isinstance(current, list) and isinstance(token, int) and token < len(current):
+ current = current[token]
+ else:
+ return _MISSING
+ return current
+
+
def execute_node(
encoded_scope: str,
encoded_source: str,
@@ -109,7 +271,13 @@ def execute_node(
"""Execute one persisted node module with a fresh module namespace."""
scope = _decode_scope(encoded_scope)
source = _decode(encoded_source)
- module: dict[str, Any] = {"__name__": "__automation_node__"}
+ module: dict[str, Any] = {
+ "__name__": "__automation_node__",
+ # Generated nodes use runtime-owned helpers through this execution boundary.
+ # This avoids depending on the worker's ambient sys.path or importing a
+ # second copy of this module from inside persisted node source.
+ "extract_data_element": extract_data_element,
+ }
# Java selects this persisted source only after authorizing the run. Keeping
# exec here makes that trust boundary explicit and avoids hidden source edits.
exec(source, module)
diff --git a/py/testing/test_semoss_automation_runtime.py b/py/testing/test_semoss_automation_runtime.py
new file mode 100644
index 0000000000..f3f3436c66
--- /dev/null
+++ b/py/testing/test_semoss_automation_runtime.py
@@ -0,0 +1,69 @@
+"""Focused regression tests for SEMOSS Automation Python runtime helpers."""
+
+import base64
+import json
+import unittest
+
+from semoss_automation_runtime import extract_data_element
+
+
+class ExtractDataElementTests(unittest.TestCase):
+ def test_reads_dot_and_array_path(self) -> None:
+ source = {"orders": [{"id": 17}]}
+
+ self.assertEqual(17, extract_data_element(source, "orders[0].id"))
+ self.assertEqual(17, extract_data_element(source, "$.orders[0].id"))
+
+ def test_reads_quoted_key_from_root(self) -> None:
+ source = {"a.b": {"value": "ok"}}
+
+ self.assertEqual("ok", extract_data_element(source, '$["a.b"].value'))
+
+ def test_reads_json_pointer(self) -> None:
+ source = {"orders": [{"id": 17}]}
+
+ self.assertEqual(17, extract_data_element(source, "/orders/0/id"))
+
+ def test_distinguishes_missing_and_null(self) -> None:
+ source = {"present": None}
+
+ self.assertEqual(
+ "missing",
+ extract_data_element(source, "absent", missing_value="missing"),
+ )
+ self.assertEqual(
+ "null",
+ extract_data_element(source, "present", null_value="null"),
+ )
+
+ def test_reads_base64_json_and_xml(self) -> None:
+ encoded = base64.b64encode(json.dumps({"value": 2}).encode()).decode()
+
+ self.assertEqual(2, extract_data_element(encoded, "value"))
+ self.assertEqual(
+ "ok",
+ extract_data_element(
+ '- ok
',
+ "root.item.#text",
+ ),
+ )
+
+ def test_reads_run_workspace_asset(self) -> None:
+ self.assertEqual(
+ "ready",
+ extract_data_element(
+ "/orders/example.json",
+ "status",
+ asset_reader=lambda path: json.dumps(
+ {"path": path, "status": "ready"}
+ ),
+ ),
+ )
+
+ def test_rejects_invalid_root_marker(self) -> None:
+ with self.assertRaisesRegex(ValueError, "root marker"):
+ extract_data_element({"value": 1}, "$value")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/prerna/reactor/automation/AGENTS.md b/src/prerna/reactor/automation/AGENTS.md
index 279a8dba32..8bfe2ce769 100644
--- a/src/prerna/reactor/automation/AGENTS.md
+++ b/src/prerna/reactor/automation/AGENTS.md
@@ -74,7 +74,7 @@ Supported native-Python runtime types are:
- `storage.list`, `storage.read`, `storage.upload`,
`storage.download`, `storage.delete`
- `vector.search`, `vector.add`, `vector.delete`
-- `function.execute`, `app.pixel`, `control.wait`, `control.if`, `control.jev`
+- `function.execute`, `app.pixel`, `control.wait`, `control.if`, `control.jev`, `control.loop`
- `agent.run`
`control.if` stores ordered `{ id, condition }` clauses evaluated only by the bounded Java
@@ -83,8 +83,14 @@ expression evaluator. The first match selects its `case:` edge; other
`questionType: "choice"` selects among arbitrary described routes; `questionType: "noul"` maps the
model's Yes probability to exactly one `{ answer: true }` route or one `{ answer: false }` route.
Both modes retain stable route IDs for `case:` edges and select `else` when confidence is
-below the configured threshold. Arbitrary fan-out from one port, loops, and parallel execution are
-rejected before execution; nonselected branch nodes are retained in history as `SKIPPED`. Trigger globals use the canonical
+below the configured threshold. `control.loop` owns a nested acyclic graph and supports bounded,
+sequential `forEach`, fixed-count `repeat`, and condition-based `while` execution. Java owns each
+pass, cancellation, and server-side limits; body nodes continue to use the ordinary node executors
+and appear under their parent loop in run history. Loop context exposes the current item/group or
+pass number. A `while` loop also exposes only the prior pass's named body outputs. Nested loops,
+agent waits inside a loop, arbitrary fan-out from one port, and parallel execution are rejected
+before execution. Nonselected branch nodes are retained in history
+as `SKIPPED`. Trigger globals use the canonical
`trigger.start.config.globals` list: each entry is `{ name, defaultValue, description? }`, with a
non-private Python-identifier name. `trigger.start.config.pythonSource` holds the optional
setup source. Java puts defaults in the runtime scope unless
diff --git a/src/prerna/reactor/automation/AutomationConstants.java b/src/prerna/reactor/automation/AutomationConstants.java
index bb1744ffb6..1d38efdba2 100644
--- a/src/prerna/reactor/automation/AutomationConstants.java
+++ b/src/prerna/reactor/automation/AutomationConstants.java
@@ -102,6 +102,12 @@ private AutomationConstants() {
public static final String WORKSPACE_ID = "WORKSPACE_ID";
public static final String MODEL_MESSAGE_ID = "MODEL_MESSAGE_ID";
public static final String AGENT_RUN_ID = "AGENT_RUN_ID";
+ /** Owning loop node for a dynamically materialized loop-body execution row. */
+ public static final String PARENT_NODE_ID = "PARENT_NODE_ID";
+ /** Zero-based loop iteration for a dynamically materialized body execution row. */
+ public static final String ITERATION_INDEX = "ITERATION_INDEX";
+ /** Canonical graph node represented by a dynamic execution row. */
+ public static final String SOURCE_NODE_ID = "SOURCE_NODE_ID";
// -- AUTOMATION_RUN_WAITS columns ---------------------------------------------
@@ -161,6 +167,7 @@ private AutomationConstants() {
public static final String NODE_STORAGE_UPLOAD = "storage.upload";
public static final String NODE_STORAGE_DOWNLOAD = "storage.download";
public static final String NODE_STORAGE_DELETE = "storage.delete";
+ public static final String NODE_DATA_EXTRACT = "data.extract";
public static final String NODE_VECTOR_SEARCH = "vector.search";
public static final String NODE_VECTOR_ADD = "vector.add";
public static final String NODE_VECTOR_DELETE = "vector.delete";
@@ -170,6 +177,7 @@ private AutomationConstants() {
public static final String NODE_CONTROL_WAIT = "control.wait";
public static final String NODE_CONTROL_IF = "control.if";
public static final String NODE_CONTROL_JEV = "control.jev";
+ public static final String NODE_CONTROL_LOOP = "control.loop";
public static final String NODE_DEVELOPER_PYTHON = "developer.python";
// -- Node config keys (node.config map fields, shared across executors)
@@ -189,6 +197,15 @@ private AutomationConstants() {
public static final String CONFIG_PIXEL = "pixel";
public static final String CONFIG_GLOBALS = "globals";
public static final String CONFIG_DEFAULT_VALUE = "defaultValue";
+ public static final String CONFIG_SOURCE = "source";
+ public static final String CONFIG_PATH = "path";
+ public static final String CONFIG_FORMAT = "format";
+ public static final String CONFIG_MISSING_VALUE = "missingValue";
+ public static final String CONFIG_NULL_VALUE = "nullValue";
+ public static final String CONFIG_OPERATION = "operation";
+ public static final String CONFIG_COLUMNS = "columns";
+ public static final String CONFIG_OPERATOR = "operator";
+ public static final String CONFIG_VALUE = "value";
public static final String CONFIG_DESCRIPTION = "description";
public static final String CONFIG_ANSWER = "answer";
public static final String JEV_QUESTION_TYPE_CHOICE = "choice";
@@ -200,6 +217,15 @@ private AutomationConstants() {
public static final String CONFIG_MAX_TURNS = "maxTurns";
public static final String CONFIG_MAX_REFLECTIONS = "maxReflections";
public static final String CONFIG_WAIT = "wait";
+ public static final String CONFIG_LOOP_MODE = "mode";
+ public static final String CONFIG_LOOP_ITEMS = "items";
+ public static final String CONFIG_LOOP_BATCH_SIZE = "batchSize";
+ public static final String CONFIG_LOOP_COUNT = "count";
+ public static final String CONFIG_LOOP_CONDITION = "condition";
+ public static final String CONFIG_LOOP_MAX_ITERATIONS = "maxIterations";
+ public static final String LOOP_MODE_FOR_EACH = "forEach";
+ public static final String LOOP_MODE_REPEAT = "repeat";
+ public static final String LOOP_MODE_WHILE = "while";
public static final String CONFIG_WAIT_TIMEOUT_MS = "waitTimeoutMs";
public static final String CONFIG_APP_ID = "appId";
@@ -215,6 +241,13 @@ private AutomationConstants() {
public static final int DEFAULT_LIST_RUNS_LIMIT = 25;
public static final int WAIT_MIN_SECONDS = 0;
public static final int WAIT_MAX_SECONDS = 3600;
+ public static final int LOOP_MIN_BATCH_SIZE = 1;
+ public static final int LOOP_MAX_BATCH_SIZE = 1_000;
+ public static final int LOOP_DEFAULT_MAX_ITERATIONS = 1_000;
+ public static final int LOOP_MAX_ITERATIONS = 10_000;
+ public static final int LOOP_MAX_BODY_NODES = 1_000;
+ /** Maximum materialized body-node rows created by one loop execution. */
+ public static final int LOOP_MAX_NODE_EXECUTIONS = 100_000;
public static final int NODE_SOURCE_MAX_BYTES = 100_000;
public static final int NODE_OUTPUT_MAX_BYTES = 5 * 1024 * 1024;
public static final int RUN_INPUTS_MAX_BYTES = 5 * 1024 * 1024;
@@ -245,6 +278,8 @@ private AutomationConstants() {
public static final String NODE_FIELD_TYPE = "type";
public static final String NODE_FIELD_LABEL = "label";
public static final String NODE_FIELD_CONFIG = "config";
+ /** Nested acyclic graph owned by a container node such as {@code control.loop}. */
+ public static final String NODE_FIELD_BODY = "body";
public static final String NODE_FIELD_OUTPUT_VAR = "outputVar";
public static final String EDGE_FIELD_SOURCE = "source";
public static final String EDGE_FIELD_TARGET = "target";
diff --git a/src/prerna/reactor/automation/AutomationRuntime.java b/src/prerna/reactor/automation/AutomationRuntime.java
index 5025c99ec0..aeb58cbcf3 100644
--- a/src/prerna/reactor/automation/AutomationRuntime.java
+++ b/src/prerna/reactor/automation/AutomationRuntime.java
@@ -56,9 +56,16 @@ public final class AutomationRuntime {
private AutomationRuntime() {
}
- public static List> nodesForRun(AutomationDefinitionValidator.ValidatedDefinition definition) {
+ public static List> nodesForRun(
+ AutomationDefinitionValidator.ValidatedDefinition definition) {
+ return nodesForGraph(definition.nodes(), definition.edges());
+ }
+
+ /** Returns graph nodes in run-history order with runtime defaults applied. */
+ public static List> nodesForGraph(List> graphNodes,
+ List> graphEdges) {
List> nodes = new ArrayList<>();
- for (Map original : controlOrderedNodes(definition)) {
+ for (Map original : controlOrderedNodes(graphNodes, graphEdges)) {
Map node = new LinkedHashMap<>(original);
node.putIfAbsent(AutomationConstants.NODE_FIELD_LABEL, node.get(AutomationConstants.NODE_FIELD_ID));
Object nodeType = node.get(AutomationConstants.NODE_FIELD_TYPE);
@@ -73,6 +80,43 @@ public static List> nodesForRun(AutomationDefinitionValidato
return nodes;
}
+ /**
+ * Returns canonical nodes from the parent graph and every loop body. Loop-body
+ * nodes remain ordinary source-owning nodes even though they are materialized
+ * into run history only when an iteration executes.
+ */
+ public static List> allNodes(
+ AutomationDefinitionValidator.ValidatedDefinition definition) {
+ List> nodes = new ArrayList<>();
+ for (Map node : definition.nodes()) {
+ nodes.add(node);
+ nodes.addAll(loopBodyNodes(node));
+ }
+ return nodes;
+ }
+
+ /** Returns the canonical nodes owned by one loop body. */
+ @SuppressWarnings("unchecked")
+ public static List> loopBodyNodes(Map loopNode) {
+ Object body = loopNode.get(AutomationConstants.NODE_FIELD_BODY);
+ if (!(body instanceof Map, ?> bodyMap)
+ || !(((Map) bodyMap).get(AutomationConstants.DOC_NODES) instanceof List> nodes)) {
+ return List.of();
+ }
+ return (List>) (List>) nodes;
+ }
+
+ /** Returns the canonical edges owned by one loop body. */
+ @SuppressWarnings("unchecked")
+ public static List> loopBodyEdges(Map loopNode) {
+ Object body = loopNode.get(AutomationConstants.NODE_FIELD_BODY);
+ if (!(body instanceof Map, ?> bodyMap)
+ || !(((Map) bodyMap).get(AutomationConstants.DOC_EDGES) instanceof List> edges)) {
+ return List.of();
+ }
+ return (List>) (List>) edges;
+ }
+
private static String defaultOutputVariable(Map node) {
String type = (String) node.get(AutomationConstants.NODE_FIELD_TYPE);
String id = (String) node.get(AutomationConstants.NODE_FIELD_ID);
@@ -89,15 +133,21 @@ private static String defaultOutputVariable(Map node) {
*/
static List> controlOrderedNodes(
AutomationDefinitionValidator.ValidatedDefinition definition) {
+ return controlOrderedNodes(definition.nodes(), definition.edges());
+ }
+
+ /** Returns a deterministic topological order for an already validated graph. */
+ static List> controlOrderedNodes(List> graphNodes,
+ List> graphEdges) {
Map> nodes = new LinkedHashMap<>();
Map incoming = new LinkedHashMap<>();
- for (Map node : definition.nodes()) {
+ for (Map node : graphNodes) {
String id = (String) node.get(AutomationConstants.NODE_FIELD_ID);
nodes.put(id, node);
incoming.put(id, 0);
}
Map> outgoing = new HashMap<>();
- for (Map edge : definition.edges()) {
+ for (Map edge : graphEdges) {
if (!AutomationConstants.EDGE_KIND_CONTROL.equals(edge.get(AutomationConstants.EDGE_FIELD_KIND))) {
continue;
}
@@ -141,8 +191,13 @@ public static String startNodeId(AutomationDefinitionValidator.ValidatedDefiniti
public static Map> controlTargets(
AutomationDefinitionValidator.ValidatedDefinition definition) {
+ return controlTargets(definition.edges());
+ }
+
+ /** Indexes each control edge by source node and source port. */
+ public static Map> controlTargets(List> edges) {
Map> targets = new LinkedHashMap<>();
- for (Map edge : definition.edges()) {
+ for (Map edge : edges) {
if (!AutomationConstants.EDGE_KIND_CONTROL.equals(edge.get(AutomationConstants.EDGE_FIELD_KIND))) {
continue;
}
@@ -154,6 +209,22 @@ public static Map> controlTargets(
return targets;
}
+ /** Returns the sole zero-incoming node in an already validated loop body. */
+ public static String graphEntryNodeId(List> nodes, List> edges) {
+ Map incoming = new LinkedHashMap<>();
+ for (Map node : nodes) {
+ incoming.put((String) node.get(AutomationConstants.NODE_FIELD_ID), 0);
+ }
+ for (Map edge : edges) {
+ if (AutomationConstants.EDGE_KIND_CONTROL.equals(edge.get(AutomationConstants.EDGE_FIELD_KIND))) {
+ incoming.compute((String) edge.get(AutomationConstants.EDGE_FIELD_TARGET),
+ (ignored, count) -> count + 1);
+ }
+ }
+ return incoming.entrySet().stream().filter(entry -> entry.getValue() == 0).map(Map.Entry::getKey).findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Loop body has no entry node."));
+ }
+
/**
* Runs one node module with the workflow scope supplied by the Java scheduler.
*/
diff --git a/src/prerna/reactor/automation/definition/AGENTS.md b/src/prerna/reactor/automation/definition/AGENTS.md
index f6381d5a35..c475cca905 100644
--- a/src/prerna/reactor/automation/definition/AGENTS.md
+++ b/src/prerna/reactor/automation/definition/AGENTS.md
@@ -15,6 +15,9 @@ the parent `../AGENTS.md` for the complete Automation contract.
- The persisted graph is canonical. Do not create a second node contract in the
UI, MCP metadata, or generated Python.
+- A compound node may own a nested acyclic graph through `body`. Validate its
+ nodes, edges, identifiers, sources, and permissions through the same catalog
+ and definition services used by the parent graph.
- Authoring may save an incomplete acyclic draft; execution validation remains
stricter and requires a connected runnable graph.
- Node types, ports, configuration fields, and output fields come from
diff --git a/src/prerna/reactor/automation/definition/AddAutomationStepReactor.java b/src/prerna/reactor/automation/definition/AddAutomationStepReactor.java
index 9b8365913d..4cbdcc64ad 100644
--- a/src/prerna/reactor/automation/definition/AddAutomationStepReactor.java
+++ b/src/prerna/reactor/automation/definition/AddAutomationStepReactor.java
@@ -119,6 +119,10 @@ private NounMetadata addStep(String projectId, AutomationDefinitionService.Defin
customSource == null ? AutomationConstants.NODE_CODE_MODE_GENERATED
: AutomationConstants.NODE_CODE_MODE_CUSTOM);
node.put(AutomationConstants.NODE_FIELD_CONFIG, config);
+ if (AutomationConstants.NODE_CONTROL_LOOP.equals(nodeType)) {
+ node.put(AutomationConstants.NODE_FIELD_BODY,
+ Map.of(AutomationConstants.DOC_NODES, List.of(), AutomationConstants.DOC_EDGES, List.of()));
+ }
node.put("position", Map.of("x", 240, "y", 80 + nodes.size() * 180));
List> updatedNodes = new ArrayList<>(nodes);
diff --git a/src/prerna/reactor/automation/definition/AutomationDefinitionService.java b/src/prerna/reactor/automation/definition/AutomationDefinitionService.java
index d35132f5fa..01a3b41e82 100644
--- a/src/prerna/reactor/automation/definition/AutomationDefinitionService.java
+++ b/src/prerna/reactor/automation/definition/AutomationDefinitionService.java
@@ -106,8 +106,8 @@ private static DefinitionFiles loadFromFolder(Path folder) {
.parseAndValidateForAuthoring(definition);
validateUniqueNodeSourceFileNames(validated);
Map sources = new LinkedHashMap<>();
- for (Map node : validated.nodes()) {
- if (isJavaOwnedRoutingNode(node)) {
+ for (Map node : AutomationRuntime.allNodes(validated)) {
+ if (isJavaOwnedNode(node)) {
continue;
}
String nodeId = (String) node.get(AutomationConstants.NODE_FIELD_ID);
@@ -239,7 +239,7 @@ public static List getArtifactPaths(String projectId) {
.parseAndValidateForAuthoring(definition.definition());
List paths = new ArrayList<>();
paths.add(definitionPath(assetsFolder));
- for (Map node : validated.nodes()) {
+ for (Map node : AutomationRuntime.allNodes(validated)) {
if (requiresPythonSource(node)) {
paths.add(nodeSourcePath(assetsFolder, node));
}
@@ -263,7 +263,7 @@ private static Map defaultNodeSources(
private static Map withoutTriggerSources(Map sources,
AutomationDefinitionValidator.ValidatedDefinition definition) {
Map result = new LinkedHashMap<>(sources);
- for (Map node : definition.nodes()) {
+ for (Map node : AutomationRuntime.allNodes(definition)) {
if (!requiresPythonSource(node)) {
result.remove(node.get(AutomationConstants.NODE_FIELD_ID));
}
@@ -275,7 +275,7 @@ private static Map validateAndCompleteNodeSources(
AutomationDefinitionValidator.ValidatedDefinition definition, Map nodeSources) {
Map supplied = nodeSources == null ? Map.of() : nodeSources;
Map> nodesById = new LinkedHashMap<>();
- for (Map node : definition.nodes()) {
+ for (Map node : AutomationRuntime.allNodes(definition)) {
String id = (String) node.get(AutomationConstants.NODE_FIELD_ID);
nodesById.put(id, node);
}
@@ -283,15 +283,15 @@ private static Map validateAndCompleteNodeSources(
if (!nodesById.containsKey(entry.getKey())) {
throw new IllegalArgumentException("Python source was supplied for an unknown node: " + entry.getKey());
}
- if (isJavaOwnedRoutingNode(nodesById.get(entry.getKey()))) {
+ if (isJavaOwnedNode(nodesById.get(entry.getKey()))) {
throw new IllegalArgumentException(
- "Routing node '" + entry.getKey() + "' is evaluated by Java and cannot have Python source.");
+ "Control node '" + entry.getKey() + "' is evaluated by Java and cannot have Python source.");
}
validateNodeSource(entry.getKey(), nodesById.get(entry.getKey()), entry.getValue());
}
Map result = new LinkedHashMap<>();
for (Map.Entry> entry : nodesById.entrySet()) {
- if (isJavaOwnedRoutingNode(entry.getValue())) {
+ if (isJavaOwnedNode(entry.getValue())) {
continue;
}
String source = supplied.get(entry.getKey());
@@ -351,9 +351,9 @@ private static String normalizeGeneratedCodeModes(AutomationDefinitionValidator.
boolean changed = false;
changed |= definition.definition().remove(AutomationConstants.DOC_NODE_SOURCES) != null;
changed |= definition.definition().remove(AutomationConstants.DOC_GLOBALS) != null;
- for (Map node : definition.nodes()) {
+ for (Map node : AutomationRuntime.allNodes(definition)) {
String nodeType = (String) node.get(AutomationConstants.NODE_FIELD_TYPE);
- if (isJavaOwnedRoutingNode(node)) {
+ if (isJavaOwnedNode(node)) {
if (!AutomationConstants.NODE_CODE_MODE_GENERATED
.equals(node.get(AutomationConstants.NODE_FIELD_CODE_MODE))) {
node.put(AutomationConstants.NODE_FIELD_CODE_MODE, AutomationConstants.NODE_CODE_MODE_GENERATED);
@@ -425,7 +425,7 @@ private static void writeAggregate(Path folder, DefinitionFiles files) throws IO
validateUniqueNodeSourceFileNames(definition);
Files.createDirectories(nodesFolder(folder));
writeReplace(definitionPath(folder), prettyJson(files.definition()));
- for (Map node : definition.nodes()) {
+ for (Map node : AutomationRuntime.allNodes(definition)) {
if (!requiresPythonSource(node)) {
continue;
}
@@ -564,7 +564,7 @@ private static String slugify(String value) {
private static void validateUniqueNodeSourceFileNames(
AutomationDefinitionValidator.ValidatedDefinition definition) {
Map nodeIdsByFileName = new LinkedHashMap<>();
- for (Map node : definition.nodes()) {
+ for (Map node : AutomationRuntime.allNodes(definition)) {
if (!requiresPythonSource(node)) {
continue;
}
@@ -580,7 +580,7 @@ private static void validateUniqueNodeSourceFileNames(
private static boolean requiresPythonSource(Map node) {
return !AutomationConstants.NODE_START.equals(node.get(AutomationConstants.NODE_FIELD_TYPE))
- && !isJavaOwnedRoutingNode(node);
+ && !isJavaOwnedNode(node);
}
/**
@@ -588,12 +588,13 @@ private static boolean requiresPythonSource(Map node) {
* runtime and therefore has no persisted Python source file.
*
* @param node automation node
- * @return {@code true} for a Java-owned routing node
+ * @return {@code true} for a node evaluated directly by Java
*/
- private static boolean isJavaOwnedRoutingNode(Map node) {
+ private static boolean isJavaOwnedNode(Map node) {
Object nodeType = node.get(AutomationConstants.NODE_FIELD_TYPE);
return AutomationConstants.NODE_CONTROL_IF.equals(nodeType)
- || AutomationConstants.NODE_CONTROL_JEV.equals(nodeType);
+ || AutomationConstants.NODE_CONTROL_JEV.equals(nodeType)
+ || AutomationConstants.NODE_CONTROL_LOOP.equals(nodeType);
}
private static String emptyDefinition() {
diff --git a/src/prerna/reactor/automation/definition/AutomationDefinitionValidator.java b/src/prerna/reactor/automation/definition/AutomationDefinitionValidator.java
index e24cde4bdb..0610a3497a 100644
--- a/src/prerna/reactor/automation/definition/AutomationDefinitionValidator.java
+++ b/src/prerna/reactor/automation/definition/AutomationDefinitionValidator.java
@@ -126,10 +126,11 @@ private static ValidatedDefinition validate(Map definition, bool
List> nodes = requireMapList(graph.get(AutomationConstants.DOC_NODES), "graph.nodes");
List> edges = requireMapList(graph.get(AutomationConstants.DOC_EDGES), "graph.edges");
- Map nodeTypes = validateNodes(nodes);
+ Map nodeTypes = validateNodes(nodes, true);
Map> branchPorts = branchPorts(nodes);
validateEdges(edges, nodeTypes, branchPorts);
validateControlPath(edges, nodeTypes, branchPorts, requireExecutableGraph);
+ validateLoopBodies(nodes, nodeTypes.keySet(), requireExecutableGraph);
validateTriggerBindings(definition.get(AutomationConstants.DOC_TRIGGER_BINDINGS));
String snapshot = AutomationRuntimeUtils.GSON.toJson(canonicalize(definition));
@@ -144,7 +145,7 @@ private static void validateVersion(Object value) {
}
}
- private static Map validateNodes(List> nodes) {
+ private static Map validateNodes(List> nodes, boolean requireStart) {
Map nodeTypes = new LinkedHashMap<>();
Set outputVariables = new HashSet<>();
int startCount = 0;
@@ -196,10 +197,10 @@ private static Map validateNodes(List> nodes
&& !AutomationConstants.NODE_CODE_MODE_CUSTOM.equals(codeMode)) {
throw new IllegalArgumentException("Node '" + nodeId + "' has unsupported codeMode: " + codeMode + ".");
}
- if (isRoutingNode(typedNode)
+ if (isJavaOwnedNode(typedNode)
&& !AutomationConstants.NODE_CODE_MODE_GENERATED.equals(codeMode)) {
throw new IllegalArgumentException(
- "Routing node '" + nodeId + "' must use generated mode; routing is evaluated by Java.");
+ "Java-owned node '" + nodeId + "' must use generated mode.");
}
if (AutomationConstants.NODE_CODE_MODE_CUSTOM.equals(codeMode) && !(config instanceof Map, ?>)) {
throw new IllegalArgumentException("Custom node '" + nodeId + "' must declare a config object.");
@@ -209,9 +210,12 @@ private static Map validateNodes(List> nodes
validateGeneratedAppPixel(nodeId, nodeType, nodeConfig);
}
}
- if (startCount != 1) {
+ if (requireStart && startCount != 1) {
throw new IllegalArgumentException("Python automation definition must contain exactly one start node.");
}
+ if (!requireStart && startCount != 0) {
+ throw new IllegalArgumentException("A loop body cannot contain a trigger.start node.");
+ }
return nodeTypes;
}
@@ -273,6 +277,7 @@ private static void validateNodeConfig(String nodeId, AutomationNodeType nodeTyp
requireConfigString(nodeId, config, "path");
validateOptionalConfigString(nodeId, config, "version");
}
+ case DATA_EXTRACT -> validateDataExtractConfig(nodeId, config);
case VECTOR_SEARCH -> {
requireConfigString(nodeId, config, "value");
validateOptionalConfigObject(nodeId, config, "filters");
@@ -299,16 +304,84 @@ private static void validateNodeConfig(String nodeId, AutomationNodeType nodeTyp
case CONTROL_WAIT -> validateWaitConfig(nodeId, config);
case CONTROL_IF -> validateBranchConfig(nodeId, config);
case CONTROL_JEV -> validateJevBranchConfig(nodeId, config);
- case STORAGE_LIST, TRIGGER_START, DEVELOPER_PYTHON -> {
+ case CONTROL_LOOP -> validateLoopConfig(nodeId, config);
+ case STORAGE_LIST -> validateOptionalStringListOrPlaceholder(nodeId, config, "extensions");
+ case TRIGGER_START, DEVELOPER_PYTHON -> {
// These node types have no additional required configuration here.
}
}
}
+ private static void validateDataExtractConfig(String nodeId, Map config) {
+ Object source = config.get(AutomationConstants.CONFIG_SOURCE);
+ if (source == null || source instanceof String string && string.isBlank()) {
+ throw new IllegalArgumentException(
+ "Node '" + nodeId + "' config.source must contain data, a scope reference, or a file path.");
+ }
+ requireConfigString(nodeId, config, AutomationConstants.CONFIG_PATH);
+ String format = config.get(AutomationConstants.CONFIG_FORMAT) instanceof String value ? value : "auto";
+ if (!("auto".equals(format) || "json".equals(format) || "xml".equals(format))) {
+ throw new IllegalArgumentException(
+ "Node '" + nodeId + "' config.format must be auto, json, or xml.");
+ }
+ }
+
private static boolean isRoutingNode(AutomationNodeType nodeType) {
return nodeType == AutomationNodeType.CONTROL_IF || nodeType == AutomationNodeType.CONTROL_JEV;
}
+ private static boolean isJavaOwnedNode(AutomationNodeType nodeType) {
+ return isRoutingNode(nodeType) || nodeType == AutomationNodeType.CONTROL_LOOP;
+ }
+
+ private static void validateLoopConfig(String nodeId, Map config) {
+ Object mode = config.getOrDefault(AutomationConstants.CONFIG_LOOP_MODE,
+ AutomationConstants.LOOP_MODE_FOR_EACH);
+ validateBoundedInteger(nodeId, config, AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS, 1,
+ AutomationConstants.LOOP_MAX_ITERATIONS);
+ if (AutomationConstants.LOOP_MODE_FOR_EACH.equals(mode)) {
+ Object items = config.get(AutomationConstants.CONFIG_LOOP_ITEMS);
+ boolean scopeReference = items instanceof String value && isScopePlaceholder(value.trim());
+ if (!(items instanceof List>) && !scopeReference) {
+ throw new IllegalArgumentException("Loop node '" + nodeId
+ + "' config.items must be an array or an exact scope reference such as ${files} or ${download.files}.");
+ }
+ validateBoundedInteger(nodeId, config, AutomationConstants.CONFIG_LOOP_BATCH_SIZE,
+ AutomationConstants.LOOP_MIN_BATCH_SIZE, AutomationConstants.LOOP_MAX_BATCH_SIZE);
+ return;
+ }
+ if (AutomationConstants.LOOP_MODE_REPEAT.equals(mode)) {
+ validateBoundedInteger(nodeId, config, AutomationConstants.CONFIG_LOOP_COUNT, 1,
+ AutomationConstants.LOOP_MAX_ITERATIONS);
+ int count = ((Number) config.get(AutomationConstants.CONFIG_LOOP_COUNT)).intValue();
+ int maximumIterations = ((Number) config.get(AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS)).intValue();
+ if (count > maximumIterations) {
+ throw new IllegalArgumentException("Loop node '" + nodeId
+ + "' config.count cannot exceed config.maxIterations.");
+ }
+ return;
+ }
+ if (AutomationConstants.LOOP_MODE_WHILE.equals(mode)) {
+ String condition = requireNonblankString(config.get(AutomationConstants.CONFIG_LOOP_CONDITION),
+ "Loop node '" + nodeId + "' config.condition");
+ AutomationConditionEvaluator.validate(condition);
+ return;
+ }
+ throw new IllegalArgumentException("Loop node '" + nodeId
+ + "' config.mode must be 'forEach', 'repeat', or 'while'.");
+ }
+
+ private static void validateBoundedInteger(String nodeId, Map config, String key, int minimum,
+ int maximum) {
+ Object value = config.get(key);
+ if (!(value instanceof Number number) || !Double.isFinite(number.doubleValue())
+ || number.doubleValue() != Math.rint(number.doubleValue()) || number.intValue() < minimum
+ || number.intValue() > maximum) {
+ throw new IllegalArgumentException("Node '" + nodeId + "' config." + key
+ + " must be a whole number from " + minimum + " through " + maximum + ".");
+ }
+ }
+
/**
* Require generated database reads to declare a finite integer row bound. The
* limit is enforced again by the server-owned {@code SqlQuery} execution path.
@@ -807,6 +880,115 @@ private static void validateControlPath(List> edges, Map> nodes, Set parentNodeIds,
+ boolean requireExecutableGraph) {
+ Set allNodeIds = new HashSet<>(parentNodeIds);
+ Set parentOutputVariables = new HashSet<>();
+ for (Map node : nodes) {
+ Object outputVariable = node.get(AutomationConstants.NODE_FIELD_OUTPUT_VAR);
+ if (outputVariable instanceof String value) {
+ parentOutputVariables.add(value);
+ }
+ }
+ for (Map node : nodes) {
+ if (!AutomationConstants.NODE_CONTROL_LOOP.equals(node.get(AutomationConstants.NODE_FIELD_TYPE))) {
+ continue;
+ }
+ String loopNodeId = (String) node.get(AutomationConstants.NODE_FIELD_ID);
+ Object rawBody = node.get(AutomationConstants.NODE_FIELD_BODY);
+ if (rawBody == null && !requireExecutableGraph) {
+ continue;
+ }
+ Map body = requireMap(rawBody, "Loop node '" + loopNodeId + "' body");
+ List> bodyNodes = requireMapList(body.get(AutomationConstants.DOC_NODES),
+ "Loop node '" + loopNodeId + "' body.nodes");
+ List> bodyEdges = requireMapList(body.get(AutomationConstants.DOC_EDGES),
+ "Loop node '" + loopNodeId + "' body.edges");
+ if (bodyNodes.isEmpty()) {
+ if (requireExecutableGraph) {
+ throw new IllegalArgumentException("Loop node '" + loopNodeId + "' requires at least one body node.");
+ }
+ continue;
+ }
+ if (bodyNodes.size() > AutomationConstants.LOOP_MAX_BODY_NODES) {
+ throw new IllegalArgumentException("Loop node '" + loopNodeId + "' body exceeds the maximum of "
+ + AutomationConstants.LOOP_MAX_BODY_NODES + " nodes.");
+ }
+ for (Map bodyNode : bodyNodes) {
+ String bodyType = String.valueOf(bodyNode.get(AutomationConstants.NODE_FIELD_TYPE));
+ if (AutomationConstants.NODE_CONTROL_LOOP.equals(bodyType)) {
+ throw new IllegalArgumentException(
+ "Loop node '" + loopNodeId + "' cannot contain a nested loop in phase 1.");
+ }
+ if (AutomationConstants.NODE_AGENT_RUN.equals(bodyType)) {
+ throw new IllegalArgumentException("Loop node '" + loopNodeId
+ + "' cannot contain an agent.run node because durable input waits inside iterations are not supported.");
+ }
+ }
+ Map bodyNodeTypes = validateNodes(bodyNodes, false);
+ for (String bodyNodeId : bodyNodeTypes.keySet()) {
+ if (!allNodeIds.add(bodyNodeId)) {
+ throw new IllegalArgumentException(
+ "Automation definition has duplicate node id: " + bodyNodeId + ".");
+ }
+ }
+ for (Map bodyNode : bodyNodes) {
+ Object outputVariable = bodyNode.get(AutomationConstants.NODE_FIELD_OUTPUT_VAR);
+ if (outputVariable instanceof String value && parentOutputVariables.contains(value)) {
+ throw new IllegalArgumentException("Loop node '" + loopNodeId + "' body outputVar '" + value
+ + "' cannot shadow a parent graph outputVar.");
+ }
+ }
+ Map> bodyBranchPorts = branchPorts(bodyNodes);
+ validateEdges(bodyEdges, bodyNodeTypes, bodyBranchPorts);
+ validateLoopBodyControlPath(loopNodeId, bodyEdges, bodyNodeTypes, bodyBranchPorts,
+ requireExecutableGraph);
+ }
+ }
+
+ private static void validateLoopBodyControlPath(String loopNodeId, List> edges,
+ Map nodeTypes, Map> branchPorts, boolean requireExecutableGraph) {
+ Map incoming = new LinkedHashMap<>();
+ for (String nodeId : nodeTypes.keySet()) {
+ incoming.put(nodeId, 0);
+ }
+ for (Map edge : edges) {
+ if (AutomationConstants.EDGE_KIND_CONTROL.equals(edge.get(AutomationConstants.EDGE_FIELD_KIND))) {
+ incoming.compute((String) edge.get(AutomationConstants.EDGE_FIELD_TARGET),
+ (ignored, count) -> count + 1);
+ }
+ }
+ List roots = incoming.entrySet().stream().filter(entry -> entry.getValue() == 0).map(Map.Entry::getKey)
+ .toList();
+ if (requireExecutableGraph && roots.size() != 1) {
+ throw new IllegalArgumentException(
+ "Loop node '" + loopNodeId + "' body must have exactly one entry node.");
+ }
+
+ String virtualStart = "__loop_body_start__";
+ while (nodeTypes.containsKey(virtualStart)) {
+ virtualStart += "_";
+ }
+ Map augmentedTypes = new LinkedHashMap<>();
+ augmentedTypes.put(virtualStart, AutomationConstants.NODE_START);
+ augmentedTypes.putAll(nodeTypes);
+ List> augmentedEdges = new ArrayList<>(edges);
+ if (requireExecutableGraph) {
+ augmentedEdges.add(Map.of(AutomationConstants.NODE_FIELD_ID, virtualStart + "-edge",
+ AutomationConstants.EDGE_FIELD_KIND, AutomationConstants.EDGE_KIND_CONTROL,
+ AutomationConstants.EDGE_FIELD_SOURCE, virtualStart, AutomationConstants.EDGE_FIELD_SOURCE_PORT,
+ AutomationConstants.CONTROL_PORT_OUT, AutomationConstants.EDGE_FIELD_TARGET, roots.get(0),
+ AutomationConstants.EDGE_FIELD_TARGET_PORT, AutomationConstants.CONTROL_PORT_IN));
+ }
+ validateControlPath(augmentedEdges, augmentedTypes, branchPorts, requireExecutableGraph);
+ }
+
private static void validateTriggerBindings(Object value) {
if (value == null) {
return;
diff --git a/src/prerna/reactor/automation/definition/AutomationNodeCatalog.java b/src/prerna/reactor/automation/definition/AutomationNodeCatalog.java
index 8b69b52fc4..da0b4c133a 100644
--- a/src/prerna/reactor/automation/definition/AutomationNodeCatalog.java
+++ b/src/prerna/reactor/automation/definition/AutomationNodeCatalog.java
@@ -158,6 +158,19 @@ private static List createDefinitions() {
definitions.add(storageDefinition(AutomationNodeType.STORAGE_DELETE, "Delete file",
"Delete a file from connected storage.", true, false));
+ definitions.add(definition(AutomationNodeType.DATA_EXTRACT, "Extract value",
+ "Read one value from JSON, XML, or a file in this run's workspace.",
+ orderedConfig(AutomationConstants.CONFIG_SOURCE, "", AutomationConstants.CONFIG_PATH, "",
+ AutomationConstants.CONFIG_FORMAT, "auto", AutomationConstants.CONFIG_MISSING_VALUE, null,
+ AutomationConstants.CONFIG_NULL_VALUE, null),
+ List.of(field(AutomationConstants.CONFIG_SOURCE, ConfigFieldType.TEXT, "Data or file path", true, ""),
+ field(AutomationConstants.CONFIG_PATH, ConfigFieldType.STRING, "Value path", true, ""),
+ field(AutomationConstants.CONFIG_FORMAT, ConfigFieldType.STRING, "Data format", true, "auto"),
+ field(AutomationConstants.CONFIG_MISSING_VALUE, ConfigFieldType.JSON,
+ "If the path is missing", false, null),
+ field(AutomationConstants.CONFIG_NULL_VALUE, ConfigFieldType.JSON, "If the value is null", false,
+ null)),
+ controlInputs(), controlAndResultOutputs()));
definitions.add(vectorDefinition(AutomationNodeType.VECTOR_SEARCH, "Search vectors",
"Search records in a vector database.", true));
definitions.add(vectorDefinition(AutomationNodeType.VECTOR_ADD, "Add vector documents",
@@ -214,6 +227,29 @@ private static List createDefinitions() {
controlInputs(),
List.of(port("case:", "Route", PortKind.CONTROL, PortDirection.OUTPUT, null),
port("else", "Fallback", PortKind.CONTROL, PortDirection.OUTPUT, null))));
+ definitions.add(definition(AutomationNodeType.CONTROL_LOOP, "Repeat steps",
+ "Run a nested sequence for each item, a fixed number of times, or while a condition is true.",
+ orderedConfig("mode", AutomationConstants.LOOP_MODE_FOR_EACH, "items", List.of(), "batchSize", 1,
+ "count", 1, "condition", "", "maxIterations",
+ AutomationConstants.LOOP_DEFAULT_MAX_ITERATIONS),
+ List.of(field("mode", ConfigFieldType.STRING, "Mode", true,
+ AutomationConstants.LOOP_MODE_FOR_EACH),
+ field("items", ConfigFieldType.JSON, "Items", false, List.of()),
+ optionalBoundedIntegerField("batchSize", "Batch size", 1,
+ AutomationConstants.LOOP_MIN_BATCH_SIZE, AutomationConstants.LOOP_MAX_BATCH_SIZE),
+ optionalBoundedIntegerField("count", "Number of times", 1, 1,
+ AutomationConstants.LOOP_MAX_ITERATIONS),
+ field("condition", ConfigFieldType.TEXT, "Continue while", false, ""),
+ boundedIntegerField("maxIterations", "Maximum iterations",
+ AutomationConstants.LOOP_DEFAULT_MAX_ITERATIONS, 1,
+ AutomationConstants.LOOP_MAX_ITERATIONS)),
+ List.of(outputField("processed", OutputFieldType.NUMBER, "Processed",
+ "Number of items processed.", true),
+ outputField("iterations", OutputFieldType.NUMBER, "Iterations",
+ "Number of loop-body executions.", true),
+ outputField("results", OutputFieldType.OBJECT_LIST, "Results",
+ "Ordered outputs collected from each iteration.", true)),
+ controlInputs(), controlAndResultOutputs()));
definitions.add(new AutomationNodeDefinition(AutomationNodeType.DEVELOPER_PYTHON, "Python",
"Run custom Python for advanced transformations.", AutomationConstants.NODE_CODE_MODE_CUSTOM, Map.of(),
List.of(), List.of(), controlInputs(), controlAndResultOutputs()));
@@ -238,6 +274,10 @@ private static AutomationNodeDefinition storageDefinition(AutomationNodeType nod
List fields = new ArrayList<>();
fields.add(engineField(IEngine.CATALOG_TYPE.STORAGE));
fields.add(field("path", ConfigFieldType.STRING, "Storage path", pathRequired, ""));
+ if (nodeType == AutomationNodeType.STORAGE_LIST) {
+ defaultConfig.put("extensions", List.of());
+ fields.add(field("extensions", ConfigFieldType.STRING_LIST, "File types", false, List.of()));
+ }
if (destinationRequired) {
fields.add(field("destination", ConfigFieldType.STRING, "Destination", requireDestination,
destinationDefault));
@@ -326,6 +366,11 @@ private static ConfigField boundedIntegerField(String key, String label, int def
return new ConfigField(key, ConfigFieldType.INTEGER, label, true, defaultValue, minimum, maximum, null);
}
+ private static ConfigField optionalBoundedIntegerField(String key, String label, int defaultValue, Integer minimum,
+ Integer maximum) {
+ return new ConfigField(key, ConfigFieldType.INTEGER, label, false, defaultValue, minimum, maximum, null);
+ }
+
private static Port port(String id, String label, PortKind kind, PortDirection direction, String dataType) {
return new Port(id, label, kind, direction, dataType);
}
diff --git a/src/prerna/reactor/automation/definition/AutomationNodeDefinition.java b/src/prerna/reactor/automation/definition/AutomationNodeDefinition.java
index cba80053ce..cbea868676 100644
--- a/src/prerna/reactor/automation/definition/AutomationNodeDefinition.java
+++ b/src/prerna/reactor/automation/definition/AutomationNodeDefinition.java
@@ -79,7 +79,8 @@ String getValue() {
/** Supported structured result value shapes exposed to authoring clients. */
public enum OutputFieldType {
- BOOLEAN("boolean"), NUMBER("number"), OBJECT("object"), STRING("string"), STRING_LIST("string[]");
+ BOOLEAN("boolean"), NUMBER("number"), OBJECT("object"), OBJECT_LIST("object[]"), STRING("string"),
+ STRING_LIST("string[]");
private final String value;
diff --git a/src/prerna/reactor/automation/definition/AutomationNodeType.java b/src/prerna/reactor/automation/definition/AutomationNodeType.java
index 7b994b0380..28db9f2f3e 100644
--- a/src/prerna/reactor/automation/definition/AutomationNodeType.java
+++ b/src/prerna/reactor/automation/definition/AutomationNodeType.java
@@ -71,6 +71,7 @@ public enum AutomationNodeType {
Permission.VIEW, true, true),
STORAGE_DELETE(AutomationConstants.NODE_STORAGE_DELETE, Category.STORAGE, IEngine.CATALOG_TYPE.STORAGE,
Permission.EDIT, true, true),
+ DATA_EXTRACT(AutomationConstants.NODE_DATA_EXTRACT, Category.DATA, null, Permission.NONE, true, false),
VECTOR_SEARCH(AutomationConstants.NODE_VECTOR_SEARCH, Category.VECTOR, IEngine.CATALOG_TYPE.VECTOR, Permission.VIEW,
true, true),
VECTOR_ADD(AutomationConstants.NODE_VECTOR_ADD, Category.VECTOR, IEngine.CATALOG_TYPE.VECTOR, Permission.EDIT, true,
@@ -86,13 +87,14 @@ public enum AutomationNodeType {
CONTROL_IF(AutomationConstants.NODE_CONTROL_IF, Category.CONTROL, null, Permission.NONE, false, false),
CONTROL_JEV(AutomationConstants.NODE_CONTROL_JEV, Category.CONTROL, IEngine.CATALOG_TYPE.MODEL, Permission.VIEW,
false, false),
+ CONTROL_LOOP(AutomationConstants.NODE_CONTROL_LOOP, Category.CONTROL, null, Permission.NONE, true, false),
DEVELOPER_PYTHON(AutomationConstants.NODE_DEVELOPER_PYTHON, Category.DEVELOPER, null, Permission.NONE, true, true);
/**
* Logical grouping used by clients without parsing the persisted type string.
*/
public enum Category {
- TRIGGER, DATABASE, MODEL, STORAGE, VECTOR, FUNCTION, APP, AGENT, CONTROL, DEVELOPER
+ TRIGGER, DATABASE, MODEL, STORAGE, DATA, VECTOR, FUNCTION, APP, AGENT, CONTROL, DEVELOPER
}
/** Minimum resource permission required by the node when it references one. */
diff --git a/src/prerna/reactor/automation/definition/AutomationSourceRenderer.java b/src/prerna/reactor/automation/definition/AutomationSourceRenderer.java
index dba70e238a..a562330945 100644
--- a/src/prerna/reactor/automation/definition/AutomationSourceRenderer.java
+++ b/src/prerna/reactor/automation/definition/AutomationSourceRenderer.java
@@ -64,8 +64,8 @@ public static String renderNode(Map node) {
: Map.of();
String source = switch (nodeType) {
case TRIGGER_START -> triggerSource();
- case CONTROL_IF, CONTROL_JEV ->
- throw new IllegalArgumentException("Routing nodes are evaluated by Java and do not have Python source.");
+ case CONTROL_IF, CONTROL_JEV, CONTROL_LOOP ->
+ throw new IllegalArgumentException("Java-owned nodes do not have Python source.");
case DATABASE_QUERY -> databaseQuerySource(config);
case DATABASE_INSERT -> databaseWriteSource(config, "insertData");
case DATABASE_UPDATE -> databaseWriteSource(config, "updateData");
@@ -74,11 +74,12 @@ public static String renderNode(Map node) {
case MODEL_EMBEDDINGS -> modelEmbeddingsSource(config);
case MODEL_VISION -> modelVisionSource(config);
case MODEL_NER -> modelNerSource(config);
- case STORAGE_LIST -> storageSource(config, "list", "STORAGE_PATH");
+ case STORAGE_LIST -> storageListSource(config);
case STORAGE_READ -> storageReadSource(config);
case STORAGE_UPLOAD -> storageUploadSource(config);
case STORAGE_DOWNLOAD -> storageDownloadSource(config);
case STORAGE_DELETE -> storageDeleteSource(config);
+ case DATA_EXTRACT -> dataExtractSource(config);
case VECTOR_SEARCH -> vectorSearchSource(config);
case VECTOR_ADD -> vectorAddSource(config);
case VECTOR_DELETE -> vectorDeleteSource(config);
@@ -334,6 +335,44 @@ def run(scope):
""".formatted(value(config, "engineId"), valueOrDefault(config, "path", ""), method, argument);
}
+ private static String storageListSource(Map config) {
+ return """
+ # List storage paths through the existing SEMOSS storage SDK.
+ from ai_server import StorageEngine
+
+ ENGINE_ID = %s
+ STORAGE_PATH = %s
+ FILE_TYPES = %s
+
+ def _normalized_suffixes(values):
+ return tuple(
+ "." + str(value).strip().lower().lstrip(".")
+ for value in values
+ if str(value).strip()
+ )
+
+ def run(scope):
+ storage = StorageEngine(engine_id=scope.resolve(ENGINE_ID))
+ details = storage.listDetails(scope.resolve(STORAGE_PATH))
+ paths = [
+ item.get("Path")
+ for item in details
+ if isinstance(item, dict) and isinstance(item.get("Path"), str)
+ ]
+ file_types = scope.resolve(FILE_TYPES)
+ if not file_types:
+ return paths
+ if isinstance(file_types, str):
+ file_types = [file_types]
+ suffixes = _normalized_suffixes(file_types)
+ return [
+ path for path in paths
+ if isinstance(path, str) and path.rstrip("/").lower().endswith(suffixes)
+ ]
+ """.formatted(value(config, "engineId"), valueOrDefault(config, "path", ""),
+ valueOrDefault(config, "extensions", List.of()));
+ }
+
private static String storageReadSource(Map config) {
return """
# Read storage content through the existing SEMOSS reactor; the Python SDK has no read method.
@@ -449,6 +488,8 @@ def run(scope):
localPath=destination,
)
files = _destination_files(destination)
+ if not files:
+ raise RuntimeError("No file was downloaded from storage path: " + storage_path)
return {
"success": copied,
"storagePath": storage_path,
@@ -461,6 +502,43 @@ def run(scope):
valueOrDefault(config, "version", ""));
}
+ private static String dataExtractSource(Map config) {
+ return """
+ # Extract a nested value from inline JSON/XML or a file in this run's Insight workspace.
+ from semoss import Insight
+ import base64
+ import json
+
+ SOURCE = %s
+ PATH = %s
+ FORMAT = %s
+ MISSING_VALUE = %s
+ NULL_VALUE = %s
+
+ def _pixel_value(name, value):
+ return name + "=[" + json.dumps(value) + "]"
+
+ def _read_insight_asset(file_path):
+ pixel = "GetInsightAssetsBase64(" + _pixel_value("filePath", file_path) + ");"
+ encoded = Insight().run_pixel(pixel, raw=False)
+ return base64.b64decode(encoded).decode("utf-8-sig")
+
+ def run(scope):
+ return extract_data_element(
+ source=scope.resolve(SOURCE),
+ path=str(scope.resolve(PATH)),
+ source_format=str(scope.resolve(FORMAT) or "auto"),
+ missing_value=scope.resolve(MISSING_VALUE),
+ null_value=scope.resolve(NULL_VALUE),
+ asset_reader=_read_insight_asset,
+ )
+ """.formatted(value(config, AutomationConstants.CONFIG_SOURCE),
+ value(config, AutomationConstants.CONFIG_PATH),
+ valueOrDefault(config, AutomationConstants.CONFIG_FORMAT, "auto"),
+ value(config, AutomationConstants.CONFIG_MISSING_VALUE),
+ value(config, AutomationConstants.CONFIG_NULL_VALUE));
+ }
+
private static String vectorSearchSource(Map config) {
return """
# Search a SEMOSS vector engine directly through the Python SDK.
diff --git a/src/prerna/reactor/automation/project/AutomationProjectService.java b/src/prerna/reactor/automation/project/AutomationProjectService.java
index 5b207ff3f3..0b065f7093 100644
--- a/src/prerna/reactor/automation/project/AutomationProjectService.java
+++ b/src/prerna/reactor/automation/project/AutomationProjectService.java
@@ -46,6 +46,7 @@
import prerna.engine.impl.model.inferencetracking.ModelInferenceLogsUtils;
import prerna.project.api.IProject;
import prerna.reactor.automation.AutomationConstants;
+import prerna.reactor.automation.AutomationRuntime;
import prerna.reactor.automation.definition.AutomationDefinitionService;
import prerna.reactor.automation.definition.AutomationDefinitionValidator;
import prerna.reactor.automation.definition.AutomationNodeType;
@@ -183,7 +184,9 @@ public static AutomationDefinitionService.DefinitionFiles saveDefinition(String
definitionJson, nodeSources);
AutomationMcpSync.sync(projectId, files.definition(), user);
syncDefinitionAssets(projectId, user, "Update automation definition");
- return files;
+ // Return the same normalized aggregate a subsequent GetAutomation call will
+ // read. This keeps the optimistic-concurrency token stable after Save.
+ return AutomationDefinitionService.load(projectId);
} finally {
projectLock.unlock();
}
@@ -225,7 +228,7 @@ public static AutomationDefinitionService.DefinitionFiles createStarterDefinitio
@SuppressWarnings("unchecked")
public static void validateDefinitionReferences(AutomationDefinitionValidator.ValidatedDefinition definition,
User user) {
- for (Map node : definition.nodes()) {
+ for (Map node : AutomationRuntime.allNodes(definition)) {
String nodeType = (String) node.get(AutomationConstants.NODE_FIELD_TYPE);
AutomationNodeType typedNode = AutomationNodeType.fromType(nodeType);
Object rawConfig = node.get(AutomationConstants.NODE_FIELD_CONFIG);
diff --git a/src/prerna/reactor/automation/run/AGENTS.md b/src/prerna/reactor/automation/run/AGENTS.md
index 5332c1553e..7e4537e030 100644
--- a/src/prerna/reactor/automation/run/AGENTS.md
+++ b/src/prerna/reactor/automation/run/AGENTS.md
@@ -18,6 +18,11 @@ contract.
- Preserve project permission checks and the atomic submitted-to-running claim.
- Execute only the definition, inputs, and source captured for that run.
- Keep node transitions, cancellation, waits, and terminal status durable.
+- A structured loop materializes independent child history rows for each
+ pass. Keep the parent graph acyclic, use the ordinary node executors for body
+ work, enforce pass and total-execution bounds before materializing rows, and
+ keep scope isolated from the parent. A `while` loop may carry only the prior
+ pass's declared body outputs into its next isolated scope.
- Keep frame display data scoped to the execution Insight. A frame is not a
durable-history contract.
- Do not pass engine objects, arbitrary Java objects, or unbounded payloads
diff --git a/src/prerna/reactor/automation/run/AutomationRunExecutionService.java b/src/prerna/reactor/automation/run/AutomationRunExecutionService.java
index fc7eb019a8..86d01a8333 100644
--- a/src/prerna/reactor/automation/run/AutomationRunExecutionService.java
+++ b/src/prerna/reactor/automation/run/AutomationRunExecutionService.java
@@ -28,11 +28,14 @@
package prerna.reactor.automation.run;
import java.io.File;
+import java.lang.reflect.Array;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -246,6 +249,8 @@ private Map executeInControlOrder(Insight executionInsight, Stri
nodeResult = executeConditionNode(runId, node, scope);
} else if (AutomationConstants.NODE_CONTROL_JEV.equals(type)) {
nodeResult = executeJevDecisionNode(executionInsight, runId, node, scope);
+ } else if (AutomationConstants.NODE_CONTROL_LOOP.equals(type)) {
+ nodeResult = executeLoopNode(executionInsight, projectId, runId, node, nodeSources, scope);
} else {
nodeResult = executeNodeSource(executionInsight, projectId, runId, node, nodeSources.get(nodeId), scope,
traceRoomIds.get(nodeId),
@@ -284,6 +289,259 @@ private Map executeInControlOrder(Insight executionInsight, Stri
return result;
}
+ /**
+ * Executes a bounded, sequential loop container. The parent graph remains
+ * acyclic; each pass materializes independent child history rows and runs the
+ * loop body's own validated acyclic control path against an isolated scope.
+ */
+ @SuppressWarnings("unchecked")
+ private Map executeLoopNode(Insight executionInsight, String projectId, String runId,
+ Map loopNode, Map nodeSources, Map parentScope) {
+ String loopNodeId = (String) loopNode.get(AutomationConstants.NODE_FIELD_ID);
+ Timestamp started = Utility.getSqlTimestampUTC(LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC));
+ long startedMs = System.currentTimeMillis();
+ AutomationRunStore.markNodeRunning(runId, loopNodeId);
+ streamNodeProgress(runId, loopNode, AutomationConstants.NODE_STATUS_RUNNING, null, null, null);
+ try {
+ Map config = (Map) loopNode.get(AutomationConstants.NODE_FIELD_CONFIG);
+ String loopMode = String.valueOf(config.getOrDefault(AutomationConstants.CONFIG_LOOP_MODE,
+ AutomationConstants.LOOP_MODE_FOR_EACH));
+ int maximumIterations = ((Number) config.get(AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS)).intValue();
+ List items = AutomationConstants.LOOP_MODE_FOR_EACH.equals(loopMode)
+ ? loopItems(config.get(AutomationConstants.CONFIG_LOOP_ITEMS), parentScope, loopNodeId)
+ : List.of();
+ int batchSize = AutomationConstants.LOOP_MODE_FOR_EACH.equals(loopMode)
+ ? ((Number) config.get(AutomationConstants.CONFIG_LOOP_BATCH_SIZE)).intValue()
+ : 1;
+ int iterationLimit = switch (loopMode) {
+ case AutomationConstants.LOOP_MODE_FOR_EACH -> items.isEmpty() ? 0
+ : (items.size() + batchSize - 1) / batchSize;
+ case AutomationConstants.LOOP_MODE_REPEAT -> ((Number) config
+ .get(AutomationConstants.CONFIG_LOOP_COUNT)).intValue();
+ case AutomationConstants.LOOP_MODE_WHILE -> maximumIterations;
+ default -> throw new IllegalArgumentException("Unsupported loop mode: " + loopMode);
+ };
+ if (iterationLimit > maximumIterations) {
+ throw new IllegalArgumentException("Loop node '" + loopNodeId + "' requires " + iterationLimit
+ + " iterations, exceeding its maxIterations value of " + maximumIterations + ".");
+ }
+
+ List> bodyNodes = AutomationRuntime.nodesForGraph(
+ AutomationRuntime.loopBodyNodes(loopNode), AutomationRuntime.loopBodyEdges(loopNode));
+ long bodyNodeExecutions = (long) iterationLimit * bodyNodes.size();
+ if (bodyNodeExecutions > AutomationConstants.LOOP_MAX_NODE_EXECUTIONS) {
+ throw new IllegalArgumentException("Loop node '" + loopNodeId + "' would execute "
+ + bodyNodeExecutions + " body steps, exceeding the maximum of "
+ + AutomationConstants.LOOP_MAX_NODE_EXECUTIONS
+ + ". Reduce the number of passes or the loop body.");
+ }
+ List> bodyEdges = AutomationRuntime.loopBodyEdges(loopNode);
+ Map> bodyTargets = AutomationRuntime.controlTargets(bodyEdges);
+ String bodyEntry = AutomationRuntime.graphEntryNodeId(bodyNodes, bodyEdges);
+ String loopOutputVar = (String) loopNode.get(AutomationConstants.NODE_FIELD_OUTPUT_VAR);
+ List> iterationResults = new ArrayList<>();
+ Map> bodyNodesById = new LinkedHashMap<>();
+ Map carriedOutputs = new LinkedHashMap<>();
+ for (Map bodyNode : bodyNodes) {
+ bodyNodesById.put((String) bodyNode.get(AutomationConstants.NODE_FIELD_ID), bodyNode);
+ Object outputVar = bodyNode.get(AutomationConstants.NODE_FIELD_OUTPUT_VAR);
+ if (AutomationConstants.LOOP_MODE_WHILE.equals(loopMode) && outputVar instanceof String value) {
+ carriedOutputs.put(value, null);
+ }
+ }
+ String whileCondition = AutomationConstants.LOOP_MODE_WHILE.equals(loopMode)
+ ? (String) config.get(AutomationConstants.CONFIG_LOOP_CONDITION)
+ : null;
+
+ for (int iteration = 0; iteration < iterationLimit; iteration++) {
+ if (AutomationRunRegistry.isCancellationRequested(runId)) {
+ throw new IllegalStateException("Run cancelled by user");
+ }
+ Map loopContext = new LinkedHashMap<>();
+ loopContext.put("index", iteration);
+ loopContext.put("number", iteration + 1);
+ loopContext.put("isFirst", iteration == 0);
+ if (AutomationConstants.LOOP_MODE_FOR_EACH.equals(loopMode)) {
+ int from = iteration * batchSize;
+ int to = Math.min(items.size(), from + batchSize);
+ List batch = new ArrayList<>(items.subList(from, to));
+ loopContext.put("batch", batch);
+ loopContext.put("total", iterationLimit);
+ loopContext.put("isLast", iteration == iterationLimit - 1);
+ if (batch.size() == 1) {
+ loopContext.put("item", batch.get(0));
+ }
+ } else if (AutomationConstants.LOOP_MODE_REPEAT.equals(loopMode)) {
+ loopContext.put("total", iterationLimit);
+ loopContext.put("isLast", iteration == iterationLimit - 1);
+ } else {
+ loopContext.put("previous", new LinkedHashMap<>(carriedOutputs));
+ loopContext.put("maximum", maximumIterations);
+ }
+
+ Map iterationScope = new LinkedHashMap<>(parentScope);
+ if (AutomationConstants.LOOP_MODE_WHILE.equals(loopMode)) {
+ iterationScope.putAll(carriedOutputs);
+ }
+ iterationScope.put(loopOutputVar, loopContext);
+ if (whileCondition != null && !AutomationConditionEvaluator.evaluate(whileCondition, iterationScope)) {
+ break;
+ }
+ Map bodyTraceRoomIds = allocateTraceRoomIds(bodyNodes);
+ Map executionNodeIds = AutomationRunStore.insertLoopIterationNodes(runId,
+ loopNodeId, iteration, bodyNodes, bodyTraceRoomIds);
+
+ Set visited = new HashSet<>();
+ Map outputs = new LinkedHashMap<>();
+ String current = bodyEntry;
+ while (current != null) {
+ if (AutomationRunRegistry.isCancellationRequested(runId)) {
+ throw new IllegalStateException("Run cancelled by user");
+ }
+ if (!visited.add(current)) {
+ throw new IllegalStateException("Loop body revisited node '" + current + "'.");
+ }
+ Map canonicalNode = bodyNodesById.get(current);
+ Map executionNode = new LinkedHashMap<>(canonicalNode);
+ executionNode.put(AutomationConstants.NODE_FIELD_ID, executionNodeIds.get(current));
+ executionNode.put(AutomationConstants.SOURCE_NODE_ID, current);
+ executionNode.put(AutomationConstants.PARENT_NODE_ID, loopNodeId);
+ executionNode.put(AutomationConstants.ITERATION_INDEX, iteration);
+ String type = (String) canonicalNode.get(AutomationConstants.NODE_FIELD_TYPE);
+ Map nodeResult;
+ if (AutomationConstants.NODE_CONTROL_IF.equals(type)) {
+ nodeResult = executeConditionNode(runId, executionNode, iterationScope);
+ } else if (AutomationConstants.NODE_CONTROL_JEV.equals(type)) {
+ nodeResult = executeJevDecisionNode(executionInsight, runId, executionNode, iterationScope);
+ } else {
+ nodeResult = executeNodeSource(executionInsight, projectId, runId, executionNode,
+ nodeSources.get(current), iterationScope, bodyTraceRoomIds.get(current), null);
+ }
+ if (!AutomationConstants.NODE_STATUS_SUCCESS.equals(nodeResult.get(AutomationConstants.STATUS))) {
+ throw new IllegalStateException("Loop body node '" + current + "' did not complete successfully.");
+ }
+ String outputVar = (String) canonicalNode.get(AutomationConstants.NODE_FIELD_OUTPUT_VAR);
+ if (outputVar != null) {
+ Object value = nodeResult.get(AutomationConstants.RESULT_OUTPUT_VALUE);
+ iterationScope.put(outputVar, value);
+ outputs.put(outputVar, value);
+ }
+ String selectedPort = AutomationConstants.CONTROL_PORT_OUT;
+ if (AutomationConstants.NODE_CONTROL_IF.equals(type)
+ || AutomationConstants.NODE_CONTROL_JEV.equals(type)) {
+ Map decision = (Map) nodeResult
+ .get(AutomationConstants.RESULT_OUTPUT_VALUE);
+ selectedPort = (String) decision.get("branch");
+ }
+ current = bodyTargets.getOrDefault(current, Map.of()).get(selectedPort);
+ }
+ List skipped = bodyNodesById.keySet().stream().filter(nodeId -> !visited.contains(nodeId))
+ .map(executionNodeIds::get).toList();
+ AutomationRunStore.skipPendingNodes(runId, skipped, "Loop branch was not selected");
+ Map iterationResult = new LinkedHashMap<>();
+ iterationResult.put("index", iteration);
+ iterationResult.put("outputs", outputs);
+ iterationResults.add(iterationResult);
+ if (AutomationConstants.LOOP_MODE_WHILE.equals(loopMode)) {
+ carriedOutputs.replaceAll((key, value) -> null);
+ carriedOutputs.putAll(outputs);
+ }
+ AutomationRuntimeUtils.toBoundedRuntimeJson(iterationResults,
+ AutomationConstants.NODE_OUTPUT_MAX_BYTES,
+ "Automation loop '" + loopNodeId + "' collected results");
+ }
+ if (whileCondition != null && iterationResults.size() == maximumIterations) {
+ Map nextContext = new LinkedHashMap<>();
+ nextContext.put("index", maximumIterations);
+ nextContext.put("number", maximumIterations + 1);
+ nextContext.put("isFirst", false);
+ nextContext.put("previous", new LinkedHashMap<>(carriedOutputs));
+ nextContext.put("maximum", maximumIterations);
+ Map nextScope = new LinkedHashMap<>(parentScope);
+ nextScope.putAll(carriedOutputs);
+ nextScope.put(loopOutputVar, nextContext);
+ if (AutomationConditionEvaluator.evaluate(whileCondition, nextScope)) {
+ throw new IllegalArgumentException("Loop node '" + loopNodeId + "' reached its maximum of "
+ + maximumIterations + " iterations while its condition was still true.");
+ }
+ }
+
+ Map value = new LinkedHashMap<>();
+ value.put("processed", AutomationConstants.LOOP_MODE_FOR_EACH.equals(loopMode) ? items.size()
+ : iterationResults.size());
+ value.put("iterations", iterationResults.size());
+ value.put("results", iterationResults);
+ String output = AutomationRuntimeUtils.toBoundedRuntimeJson(value,
+ AutomationConstants.NODE_OUTPUT_MAX_BYTES, "Automation loop '" + loopNodeId + "' output");
+ Map prospectiveScope = new LinkedHashMap<>(parentScope);
+ prospectiveScope.put(loopOutputVar, value);
+ AutomationRuntimeUtils.toBoundedRuntimeJson(prospectiveScope, AutomationConstants.RUN_SCOPE_MAX_BYTES,
+ "Automation run scope");
+ long duration = System.currentTimeMillis() - startedMs;
+ String preview = AutomationRuntimeUtils.generatePreview(output);
+ AutomationRunStore.updateNodeSuccess(runId, loopNodeId, started, duration, loopOutputVar, output,
+ preview, null, null);
+ AutomationRunRegistry.nodeCompleted(runId);
+ streamNodeProgress(runId, loopNode, AutomationConstants.NODE_STATUS_SUCCESS, duration, preview, null);
+ return nodeResult(loopNodeId, AutomationConstants.NODE_STATUS_SUCCESS, value, null);
+ } catch (Exception e) {
+ long duration = System.currentTimeMillis() - startedMs;
+ String message = safeMessage(e);
+ AutomationRunStore.updateNodeFailed(runId, loopNodeId, started, duration, message);
+ streamNodeProgress(runId, loopNode, AutomationConstants.NODE_STATUS_FAILED, duration, null, message);
+ throw e instanceof RuntimeException runtimeException ? runtimeException : new RuntimeException(e);
+ }
+ }
+
+ /** Resolves a loop's literal list or exact scope reference without string coercion. */
+ static List loopItems(Object configuredItems, Map scope, String loopNodeId) {
+ Object resolved = configuredItems;
+ if (configuredItems instanceof String value) {
+ Matcher reference = EXACT_SCOPE_REFERENCE.matcher(value.trim());
+ if (reference.matches()) {
+ resolved = loopScopeValue(reference.group(1), scope, loopNodeId);
+ }
+ }
+ if (resolved instanceof Collection> collection) {
+ return new ArrayList<>(collection);
+ }
+ if (resolved != null && resolved.getClass().isArray()) {
+ List items = new ArrayList<>(Array.getLength(resolved));
+ for (int index = 0; index < Array.getLength(resolved); index++) {
+ items.add(Array.get(resolved, index));
+ }
+ return items;
+ }
+ throw new IllegalArgumentException("Loop node '" + loopNodeId + "' items must resolve to an array or list.");
+ }
+
+ /** Resolves a generated loop's exact dotted scope path without coercing its value. */
+ private static Object loopScopeValue(String path, Map scope, String loopNodeId) {
+ String[] parts = path.split("\\.");
+ Object current = scope.get(parts[0]);
+ if (!scope.containsKey(parts[0])) {
+ throw new IllegalArgumentException(
+ "Loop node '" + loopNodeId + "' references unavailable scope value: " + path);
+ }
+ for (int index = 1; index < parts.length; index++) {
+ String part = parts[index];
+ if (current instanceof Map, ?> map && map.containsKey(part)) {
+ current = map.get(part);
+ continue;
+ }
+ if (current instanceof List> list && part.matches("[0-9]+")) {
+ int listIndex = Integer.parseInt(part);
+ if (listIndex < list.size()) {
+ current = list.get(listIndex);
+ continue;
+ }
+ }
+ throw new IllegalArgumentException(
+ "Loop node '" + loopNodeId + "' cannot resolve scope value: " + path);
+ }
+ return current;
+ }
+
/**
* Evaluates one typed routing question through the configured TypeSafe/Jev
* engine. The model may select only a configured route; responses below the
@@ -1635,6 +1893,12 @@ private void streamNodeProgress(String runId, Map node, String s
data.put(AutomationConstants.NODE_ID, node.get(AutomationConstants.NODE_FIELD_ID));
data.put(AutomationConstants.NODE_LABEL, node.get(AutomationConstants.NODE_FIELD_LABEL));
data.put(AutomationConstants.STATUS, status);
+ for (String field : List.of(AutomationConstants.SOURCE_NODE_ID, AutomationConstants.PARENT_NODE_ID,
+ AutomationConstants.ITERATION_INDEX)) {
+ if (node.get(field) != null) {
+ data.put(field, node.get(field));
+ }
+ }
if (durationMs != null) {
data.put(AutomationConstants.DURATION_MS, durationMs);
}
diff --git a/src/prerna/reactor/automation/run/AutomationRunStore.java b/src/prerna/reactor/automation/run/AutomationRunStore.java
index 86856a96aa..06a930598a 100644
--- a/src/prerna/reactor/automation/run/AutomationRunStore.java
+++ b/src/prerna/reactor/automation/run/AutomationRunStore.java
@@ -142,6 +142,8 @@
public final class AutomationRunStore {
private static final Logger classLogger = LogManager.getLogger(AutomationRunStore.class);
+ /** Keeps dynamically materialized body rows after the stable parent graph rows. */
+ private static final int LOOP_EXECUTION_ORDER_OFFSET = 1_000_000;
/** Prefix identifying the automation tables inside the scheduler OWL schema. */
private static final String AUTOMATION_TABLE_PREFIX = "AUTOMATION_";
@@ -237,6 +239,19 @@ private AutomationRunStore() {
(RUN_ID, NODE_ID, NODE_LABEL, EXECUTION_ORDER, STATUS, ROOM_ID, WORKSPACE_ID) \
VALUES (?, ?, ?, ?, ?, ?, ?)""";
+ private static final String INSERT_LOOP_NODE_OUTPUT = """
+ INSERT INTO AUTOMATION_NODE_OUTPUTS \
+ (RUN_ID, NODE_ID, NODE_LABEL, EXECUTION_ORDER, STATUS, ROOM_ID, WORKSPACE_ID, \
+ PARENT_NODE_ID, ITERATION_INDEX, SOURCE_NODE_ID) \
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""";
+
+ private static final String INCREMENT_RUN_TOTAL_NODES =
+ "UPDATE AUTOMATION_RUNS SET TOTAL_NODES = TOTAL_NODES + ? WHERE RUN_ID = ?";
+
+ private static final String SKIP_PENDING_NODE_OUTPUT = """
+ UPDATE AUTOMATION_NODE_OUTPUTS SET STATUS = ?, ERROR_MESSAGE = ? \
+ WHERE RUN_ID = ? AND NODE_ID = ? AND STATUS = ?""";
+
private static final String UPDATE_NODE_OUTPUT_SUCCESS = """
UPDATE AUTOMATION_NODE_OUTPUTS SET STATUS = ?, STARTED_AT = ?, COMPLETED_AT = ?, \
DURATION_MS = ?, OUTPUT_VAR_NAME = ?, OUTPUT_VALUE = ?, OUTPUT_PREVIEW = ?, \
@@ -721,6 +736,60 @@ private static void insertAllNodeOutputs(Connection conn, AbstractSqlQueryUtil q
}
}
+ /**
+ * Materializes one loop iteration into the existing node-history table. The
+ * runtime row receives its own ID while SOURCE_NODE_ID keeps the stable graph
+ * identity, so repeated executions never overwrite each other and clients do
+ * not have to parse synthetic identifiers.
+ *
+ * @param runId owning run
+ * @param loopNodeId canonical parent loop node
+ * @param iterationIndex zero-based iteration
+ * @param orderedNodes body nodes in deterministic execution-history order
+ * @param traceRoomIds optional conversational room IDs keyed by source node
+ * @return runtime node IDs keyed by canonical body node ID
+ */
+ public static Map insertLoopIterationNodes(String runId, String loopNodeId, int iterationIndex,
+ List> orderedNodes, Map traceRoomIds) {
+ IRDBMSEngine schedulerDb = requireSchedulerDb("initialize an automation loop iteration");
+ try {
+ return QueryExecutionUtility.write(schedulerDb, conn -> {
+ Map executionNodeIds = new LinkedHashMap<>();
+ try (PreparedStatement total = conn.prepareStatement(INCREMENT_RUN_TOTAL_NODES);
+ PreparedStatement output = conn.prepareStatement(INSERT_LOOP_NODE_OUTPUT)) {
+ total.setInt(1, orderedNodes.size());
+ total.setString(2, runId);
+ requireSingleRow(total.executeUpdate(), "extend the loop run node count", runId, loopNodeId);
+ for (int index = 0; index < orderedNodes.size(); index++) {
+ Map node = orderedNodes.get(index);
+ String sourceNodeId = (String) node.get(NODE_FIELD_ID);
+ String executionNodeId = UUID.randomUUID().toString();
+ executionNodeIds.put(sourceNodeId, executionNodeId);
+ int parameter = 1;
+ output.setString(parameter++, runId);
+ output.setString(parameter++, executionNodeId);
+ output.setString(parameter++, (String) node.get(NODE_FIELD_LABEL));
+ output.setInt(parameter++, LOOP_EXECUTION_ORDER_OFFSET
+ + iterationIndex * Math.max(1, orderedNodes.size()) + index);
+ output.setString(parameter++, NODE_STATUS_PENDING);
+ schedulerDb.getQueryUtil().setNullableString(output, parameter++,
+ traceRoomIds == null ? null : traceRoomIds.get(sourceNodeId));
+ schedulerDb.getQueryUtil().setNullableString(output, parameter++,
+ AutomationRuntime.configuredAgentWorkspaceId(node));
+ output.setString(parameter++, loopNodeId);
+ output.setInt(parameter++, iterationIndex);
+ output.setString(parameter++, sourceNodeId);
+ output.addBatch();
+ }
+ output.executeBatch();
+ }
+ return executionNodeIds;
+ });
+ } catch (Exception e) {
+ throw new IllegalStateException("Unable to initialize the automation loop iteration.", e);
+ }
+ }
+
/**
* Sets the cluster-safe cancellation flag on a run. Called by
* {@code CancelAutomationRunReactor} regardless of which pod receives the
@@ -1071,6 +1140,38 @@ public static void skipPendingNodes(String runId, String reason) {
}
}
+ /**
+ * Marks only the unselected nodes in one materialized loop iteration skipped.
+ *
+ * @param runId owning run
+ * @param nodeIds runtime node IDs that remain pending
+ * @param reason persisted skip explanation
+ */
+ public static void skipPendingNodes(String runId, List nodeIds, String reason) {
+ if (nodeIds == null || nodeIds.isEmpty()) {
+ return;
+ }
+ IRDBMSEngine schedulerDb = requireSchedulerDb("persist skipped automation loop nodes");
+ try {
+ QueryExecutionUtility.write(schedulerDb, conn -> {
+ try (PreparedStatement ps = conn.prepareStatement(SKIP_PENDING_NODE_OUTPUT)) {
+ for (String nodeId : nodeIds) {
+ ps.setString(1, NODE_STATUS_SKIPPED);
+ schedulerDb.getQueryUtil().setNullableString(ps, 2, reason);
+ ps.setString(3, runId);
+ ps.setString(4, nodeId);
+ ps.setString(5, NODE_STATUS_PENDING);
+ ps.addBatch();
+ }
+ ps.executeBatch();
+ }
+ return null;
+ });
+ } catch (Exception e) {
+ throw new IllegalStateException("Unable to persist skipped automation loop nodes.", e);
+ }
+ }
+
/**
* Updates a node output after successful execution.
*/
@@ -1218,6 +1319,12 @@ public static List> getNodeOutputsForRun(String runId) {
qs.addSelector(new QueryColumnSelector(TABLE_NODE_OUTPUTS + "__" + WORKSPACE_ID, WORKSPACE_ID));
qs.addSelector(new QueryColumnSelector(TABLE_NODE_OUTPUTS + "__" + MODEL_MESSAGE_ID, MODEL_MESSAGE_ID));
qs.addSelector(new QueryColumnSelector(TABLE_NODE_OUTPUTS + "__" + AGENT_RUN_ID, AGENT_RUN_ID));
+ qs.addSelector(new QueryColumnSelector(TABLE_NODE_OUTPUTS + "__" + AutomationConstants.PARENT_NODE_ID,
+ AutomationConstants.PARENT_NODE_ID));
+ qs.addSelector(new QueryColumnSelector(TABLE_NODE_OUTPUTS + "__" + AutomationConstants.ITERATION_INDEX,
+ AutomationConstants.ITERATION_INDEX));
+ qs.addSelector(new QueryColumnSelector(TABLE_NODE_OUTPUTS + "__" + AutomationConstants.SOURCE_NODE_ID,
+ AutomationConstants.SOURCE_NODE_ID));
qs.addSelector(new QueryColumnSelector(TABLE_NODE_OUTPUTS + "__" + ERROR_MESSAGE, ERROR_MESSAGE));
qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter(TABLE_NODE_OUTPUTS + "__" + RUN_ID, "==", runId,
@@ -1288,12 +1395,17 @@ public static boolean hasAgentRunTrace(String projectId, String automationRunId,
*/
public static List> buildNodeResults(List> nodeOutputs) {
List> nodeResults = new ArrayList<>();
+ Map> parentResults = new LinkedHashMap<>();
+ Map>>> loopIterations = new LinkedHashMap<>();
if (nodeOutputs == null) {
return nodeResults;
}
for (Map output : nodeOutputs) {
- Map nodeResult = new HashMap<>();
- nodeResult.put(AutomationConstants.NODE_ID, output.get(AutomationConstants.NODE_ID));
+ Map nodeResult = new LinkedHashMap<>();
+ Object parentNodeId = output.get(AutomationConstants.PARENT_NODE_ID);
+ Object sourceNodeId = output.get(AutomationConstants.SOURCE_NODE_ID);
+ nodeResult.put(AutomationConstants.NODE_ID,
+ sourceNodeId == null ? output.get(AutomationConstants.NODE_ID) : sourceNodeId);
nodeResult.put(AutomationConstants.NODE_LABEL, output.get(AutomationConstants.NODE_LABEL));
nodeResult.put(AutomationConstants.STATUS, output.get(AutomationConstants.STATUS));
nodeResult.put(AutomationConstants.DURATION_MS, output.get(AutomationConstants.DURATION_MS));
@@ -1322,7 +1434,27 @@ public static List> buildNodeResults(List new LinkedHashMap<>())
+ .computeIfAbsent(iteration, ignored -> new ArrayList<>()).add(nodeResult);
+ }
+ }
+ for (Map.Entry>>> parent : loopIterations.entrySet()) {
+ Map parentResult = parentResults.get(parent.getKey());
+ if (parentResult == null) {
+ continue;
+ }
+ List> iterations = new ArrayList<>();
+ for (Map.Entry>> iteration : parent.getValue().entrySet()) {
+ iterations.add(Map.of("index", iteration.getKey(), "nodeResults", iteration.getValue()));
+ }
+ parentResult.put("iterations", iterations);
}
return nodeResults;
}
@@ -1372,13 +1504,6 @@ private static void requireSingleRow(int updatedRows, String operation, String r
+ ": expected one row but updated " + updatedRows + ".");
}
- /**
- * Binds a nullable VARCHAR column value, using {@code setNull(Types.VARCHAR)}
- * instead of {@code setString(index, null)} when the value is absent - some
- * JDBC drivers require an explicit SQL type for a null bind rather than
- * inferring it from a null String argument.
- */
-
private static Timestamp toTimestamp(Instant instant) {
return Utility.getSqlTimestampUTC(LocalDateTime.ofInstant(instant, ZoneOffset.UTC));
}
diff --git a/src/prerna/reactor/automation/utils/AutomationRuntimeUtils.java b/src/prerna/reactor/automation/utils/AutomationRuntimeUtils.java
index f8c8ea12e0..dc0335a979 100644
--- a/src/prerna/reactor/automation/utils/AutomationRuntimeUtils.java
+++ b/src/prerna/reactor/automation/utils/AutomationRuntimeUtils.java
@@ -37,7 +37,6 @@
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
-import java.util.ArrayDeque;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
@@ -167,9 +166,8 @@ public static Map parseJsonObject(String value, String field) {
/**
* Validates container depth before serialization, then limits the encoded UTF-8
- * payload. Runtime values originate as JSON, so repeated container identity has
- * no defined meaning and is rejected with cycles before Gson can recurse
- * indefinitely.
+ * payload. Repeated references serialize as ordinary duplicated JSON values;
+ * only an object that appears again on its active ancestor path is cyclic.
*
* @param value runtime value to serialize
* @param maxBytes maximum UTF-8 payload size
@@ -187,37 +185,39 @@ public static String toBoundedRuntimeJson(Object value, int maxBytes, String val
}
private static void validateRuntimeDepth(Object value, String valueName) {
- ArrayDeque pending = new ArrayDeque<>();
- Set visitedContainers = Collections.newSetFromMap(new IdentityHashMap<>());
- pending.push(new RuntimeValueDepth(value, 0));
- while (!pending.isEmpty()) {
- RuntimeValueDepth current = pending.pop();
- Object currentValue = current.value();
- if (!(currentValue instanceof Map, ?>) && !(currentValue instanceof Iterable>)
- && (currentValue == null || !currentValue.getClass().isArray())) {
- continue;
- }
- if (current.depth() >= AutomationConstants.RUNTIME_JSON_MAX_DEPTH) {
- throw new IllegalArgumentException(valueName + " exceeds the maximum JSON depth of "
- + AutomationConstants.RUNTIME_JSON_MAX_DEPTH + ".");
- }
- if (!visitedContainers.add(currentValue)) {
- throw new IllegalArgumentException(valueName + " contains a repeated or cyclic JSON container.");
- }
- int childDepth = current.depth() + 1;
- if (currentValue instanceof Map, ?> map) {
+ Set ancestors = Collections.newSetFromMap(new IdentityHashMap<>());
+ validateRuntimeDepth(value, valueName, 0, ancestors);
+ }
+
+ private static void validateRuntimeDepth(Object value, String valueName, int depth, Set ancestors) {
+ if (!(value instanceof Map, ?>) && !(value instanceof Iterable>)
+ && (value == null || !value.getClass().isArray())) {
+ return;
+ }
+ if (depth >= AutomationConstants.RUNTIME_JSON_MAX_DEPTH) {
+ throw new IllegalArgumentException(valueName + " exceeds the maximum JSON depth of "
+ + AutomationConstants.RUNTIME_JSON_MAX_DEPTH + ".");
+ }
+ if (!ancestors.add(value)) {
+ throw new IllegalArgumentException(valueName + " contains a cyclic JSON container.");
+ }
+ try {
+ int childDepth = depth + 1;
+ if (value instanceof Map, ?> map) {
for (Object child : map.values()) {
- pending.push(new RuntimeValueDepth(child, childDepth));
+ validateRuntimeDepth(child, valueName, childDepth, ancestors);
}
- } else if (currentValue instanceof Iterable> iterable) {
+ } else if (value instanceof Iterable> iterable) {
for (Object child : iterable) {
- pending.push(new RuntimeValueDepth(child, childDepth));
+ validateRuntimeDepth(child, valueName, childDepth, ancestors);
}
} else {
- for (int index = 0; index < Array.getLength(currentValue); index++) {
- pending.push(new RuntimeValueDepth(Array.get(currentValue, index), childDepth));
+ for (int index = 0; index < Array.getLength(value); index++) {
+ validateRuntimeDepth(Array.get(value, index), valueName, childDepth, ancestors);
}
}
+ } finally {
+ ancestors.remove(value);
}
}
@@ -235,6 +235,4 @@ public static String generatePreview(String s) {
: s.substring(0, AutomationConstants.OUTPUT_PREVIEW_MAX_LENGTH);
}
- private record RuntimeValueDepth(Object value, int depth) {
- }
}
diff --git a/src/prerna/reactor/scheduler/SchedulerOwlCreator.java b/src/prerna/reactor/scheduler/SchedulerOwlCreator.java
index 45bb63eb0c..ac0ba1348f 100644
--- a/src/prerna/reactor/scheduler/SchedulerOwlCreator.java
+++ b/src/prerna/reactor/scheduler/SchedulerOwlCreator.java
@@ -331,6 +331,9 @@ public void createColumnsAndTypes(AbstractSqlQueryUtil queryUtil) {
Pair.with(AutomationConstants.WORKSPACE_ID, AutomationConstants.VARCHAR_50),
Pair.with(AutomationConstants.MODEL_MESSAGE_ID, AutomationConstants.VARCHAR_50),
Pair.with(AutomationConstants.AGENT_RUN_ID, AutomationConstants.VARCHAR_50),
+ Pair.with(AutomationConstants.PARENT_NODE_ID, AutomationConstants.VARCHAR_255),
+ Pair.with(AutomationConstants.ITERATION_INDEX, AutomationConstants.INTEGER),
+ Pair.with(AutomationConstants.SOURCE_NODE_ID, AutomationConstants.VARCHAR_255),
Pair.with(AutomationConstants.ERROR_MESSAGE, CLOB_DATATYPE)));
addTable(AutomationConstants.TABLE_AUTOMATION_RUN_WAITS, Arrays.asList(
diff --git a/test/prerna/reactor/automation/AutomationRuntimeUnitTests.java b/test/prerna/reactor/automation/AutomationRuntimeUnitTests.java
index d278b9b9f4..c59abf9d94 100644
--- a/test/prerna/reactor/automation/AutomationRuntimeUnitTests.java
+++ b/test/prerna/reactor/automation/AutomationRuntimeUnitTests.java
@@ -39,6 +39,7 @@
import org.junit.jupiter.api.Test;
import prerna.reactor.automation.definition.AutomationDefinitionService;
+import prerna.reactor.automation.definition.AutomationDefinitionValidator;
/**
* Covers how the trigger node contributes to a run. The trigger is the only node
@@ -117,4 +118,24 @@ void rejectsNestedOrMissingRunBindings() {
.definesRunEntryPoint("class Job:\n def run(self, scope):\n return {}\n"));
assertFalse(AutomationDefinitionService.definesRunEntryPoint("def outer():\n run = 1\n return run\n"));
}
+
+ @Test
+ void includesLoopBodyNodesWhenCollectingSourceOwners() {
+ Map loop = new LinkedHashMap<>();
+ loop.put(AutomationConstants.NODE_FIELD_ID, "loop");
+ loop.put(AutomationConstants.NODE_FIELD_TYPE, AutomationConstants.NODE_CONTROL_LOOP);
+ loop.put(AutomationConstants.NODE_FIELD_BODY,
+ Map.of(AutomationConstants.DOC_NODES,
+ List.of(Map.of(AutomationConstants.NODE_FIELD_ID, "body-node",
+ AutomationConstants.NODE_FIELD_TYPE, AutomationConstants.NODE_DEVELOPER_PYTHON)),
+ AutomationConstants.DOC_EDGES, List.of()));
+ Map document = Map.of(AutomationConstants.DOC_GRAPH,
+ Map.of(AutomationConstants.DOC_NODES, List.of(triggerNode(Map.of()), loop),
+ AutomationConstants.DOC_EDGES, List.of()));
+ AutomationDefinitionValidator.ValidatedDefinition definition = new AutomationDefinitionValidator.ValidatedDefinition(
+ document, List.of(triggerNode(Map.of()), loop), List.of(), "", "");
+
+ assertEquals(List.of("start", "loop", "body-node"), AutomationRuntime.allNodes(definition).stream()
+ .map(node -> String.valueOf(node.get(AutomationConstants.NODE_FIELD_ID))).toList());
+ }
}
diff --git a/test/prerna/reactor/automation/definition/AutomationDefinitionValidatorUnitTests.java b/test/prerna/reactor/automation/definition/AutomationDefinitionValidatorUnitTests.java
index e26e876605..442b6147a5 100644
--- a/test/prerna/reactor/automation/definition/AutomationDefinitionValidatorUnitTests.java
+++ b/test/prerna/reactor/automation/definition/AutomationDefinitionValidatorUnitTests.java
@@ -364,4 +364,106 @@ void rejectsAnUnknownFormatVersion() {
assertThrows(IllegalArgumentException.class,
() -> AutomationDefinitionValidator.parseAndValidateForAuthoring(json));
}
+
+ @Test
+ void acceptsABoundedLoopWithAnAcyclicBody() {
+ Map bodyNode = workNode(AutomationConstants.NODE_DEVELOPER_PYTHON, Map.of());
+ bodyNode.put(AutomationConstants.NODE_FIELD_ID, "body-python");
+ bodyNode.put(AutomationConstants.NODE_FIELD_OUTPUT_VAR, "body_result");
+ bodyNode.put(AutomationConstants.NODE_FIELD_CODE_MODE, AutomationConstants.NODE_CODE_MODE_CUSTOM);
+ Map loop = workNode(AutomationConstants.NODE_CONTROL_LOOP,
+ Map.of(AutomationConstants.CONFIG_LOOP_MODE, AutomationConstants.LOOP_MODE_FOR_EACH,
+ AutomationConstants.CONFIG_LOOP_ITEMS, List.of(1, 2, 3),
+ AutomationConstants.CONFIG_LOOP_BATCH_SIZE, 1,
+ AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS, 10));
+ loop.put(AutomationConstants.NODE_FIELD_BODY,
+ Map.of(AutomationConstants.DOC_NODES, List.of(bodyNode), AutomationConstants.DOC_EDGES, List.of()));
+
+ AutomationDefinitionValidator.parseAndValidate(definition(Map.of(), loop));
+ }
+
+ @Test
+ void acceptsDottedForEachRepeatAndWhileLoopConfiguration() {
+ Map dottedForEach = workNode(AutomationConstants.NODE_CONTROL_LOOP,
+ Map.of(AutomationConstants.CONFIG_LOOP_MODE, AutomationConstants.LOOP_MODE_FOR_EACH,
+ AutomationConstants.CONFIG_LOOP_ITEMS, "${download.files}",
+ AutomationConstants.CONFIG_LOOP_BATCH_SIZE, 1,
+ AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS, 10));
+ AutomationDefinitionValidator.parseAndValidateForAuthoring(definition(Map.of(), dottedForEach));
+
+ Map repeat = workNode(AutomationConstants.NODE_CONTROL_LOOP,
+ Map.of(AutomationConstants.CONFIG_LOOP_MODE, AutomationConstants.LOOP_MODE_REPEAT,
+ AutomationConstants.CONFIG_LOOP_COUNT, 5,
+ AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS, 10));
+ AutomationDefinitionValidator.parseAndValidateForAuthoring(definition(Map.of(), repeat));
+
+ Map whileLoop = workNode(AutomationConstants.NODE_CONTROL_LOOP,
+ Map.of(AutomationConstants.CONFIG_LOOP_MODE, AutomationConstants.LOOP_MODE_WHILE,
+ AutomationConstants.CONFIG_LOOP_CONDITION, "${status} != \"complete\"",
+ AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS, 10));
+ AutomationDefinitionValidator.parseAndValidateForAuthoring(definition(Map.of(), whileLoop));
+ }
+
+ @Test
+ void rejectsRepeatCountAboveItsSafetyLimitAndInvalidWhileCondition() {
+ Map repeat = workNode(AutomationConstants.NODE_CONTROL_LOOP,
+ Map.of(AutomationConstants.CONFIG_LOOP_MODE, AutomationConstants.LOOP_MODE_REPEAT,
+ AutomationConstants.CONFIG_LOOP_COUNT, 11,
+ AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS, 10));
+ assertThrows(IllegalArgumentException.class,
+ () -> AutomationDefinitionValidator.parseAndValidateForAuthoring(definition(Map.of(), repeat)));
+
+ Map whileLoop = workNode(AutomationConstants.NODE_CONTROL_LOOP,
+ Map.of(AutomationConstants.CONFIG_LOOP_MODE, AutomationConstants.LOOP_MODE_WHILE,
+ AutomationConstants.CONFIG_LOOP_CONDITION, "status is complete",
+ AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS, 10));
+ assertThrows(IllegalArgumentException.class,
+ () -> AutomationDefinitionValidator.parseAndValidateForAuthoring(definition(Map.of(), whileLoop)));
+ }
+
+ @Test
+ void permitsAnIncompleteLoopBodyOnlyWhileAuthoring() {
+ Map loop = workNode(AutomationConstants.NODE_CONTROL_LOOP,
+ Map.of(AutomationConstants.CONFIG_LOOP_MODE, AutomationConstants.LOOP_MODE_FOR_EACH,
+ AutomationConstants.CONFIG_LOOP_ITEMS, List.of(),
+ AutomationConstants.CONFIG_LOOP_BATCH_SIZE, 1,
+ AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS, 10));
+ String json = definition(Map.of(), loop);
+
+ AutomationDefinitionValidator.parseAndValidateForAuthoring(json);
+ assertThrows(IllegalArgumentException.class, () -> AutomationDefinitionValidator.parseAndValidate(json));
+ }
+
+ @Test
+ void rejectsNestedLoops() {
+ Map nested = workNode(AutomationConstants.NODE_CONTROL_LOOP,
+ Map.of(AutomationConstants.CONFIG_LOOP_MODE, AutomationConstants.LOOP_MODE_FOR_EACH,
+ AutomationConstants.CONFIG_LOOP_ITEMS, List.of(1),
+ AutomationConstants.CONFIG_LOOP_BATCH_SIZE, 1,
+ AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS, 10));
+ nested.put(AutomationConstants.NODE_FIELD_ID, "nested-loop");
+ Map loop = workNode(AutomationConstants.NODE_CONTROL_LOOP,
+ Map.of(AutomationConstants.CONFIG_LOOP_MODE, AutomationConstants.LOOP_MODE_FOR_EACH,
+ AutomationConstants.CONFIG_LOOP_ITEMS, List.of(1),
+ AutomationConstants.CONFIG_LOOP_BATCH_SIZE, 1,
+ AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS, 10));
+ loop.put(AutomationConstants.NODE_FIELD_BODY,
+ Map.of(AutomationConstants.DOC_NODES, List.of(nested), AutomationConstants.DOC_EDGES, List.of()));
+
+ assertThrows(IllegalArgumentException.class,
+ () -> AutomationDefinitionValidator.parseAndValidate(definition(Map.of(), loop)));
+ }
+
+ @Test
+ void rejectsAnUnboundedLoop() {
+ Map loop = workNode(AutomationConstants.NODE_CONTROL_LOOP,
+ Map.of(AutomationConstants.CONFIG_LOOP_MODE, AutomationConstants.LOOP_MODE_FOR_EACH,
+ AutomationConstants.CONFIG_LOOP_ITEMS, List.of(1),
+ AutomationConstants.CONFIG_LOOP_BATCH_SIZE, 1,
+ AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS,
+ AutomationConstants.LOOP_MAX_ITERATIONS + 1));
+
+ assertThrows(IllegalArgumentException.class,
+ () -> AutomationDefinitionValidator.parseAndValidateForAuthoring(definition(Map.of(), loop)));
+ }
}
diff --git a/test/prerna/reactor/automation/definition/AutomationNodeCatalogUnitTests.java b/test/prerna/reactor/automation/definition/AutomationNodeCatalogUnitTests.java
index 9829bd573a..f551cc3913 100644
--- a/test/prerna/reactor/automation/definition/AutomationNodeCatalogUnitTests.java
+++ b/test/prerna/reactor/automation/definition/AutomationNodeCatalogUnitTests.java
@@ -106,6 +106,31 @@ void storageDownloadAdvertisesItsStructuredResult() {
assertEquals(false, fieldsByKey.get("filePath").get("required"));
}
+ @Test
+ void storageListAdvertisesOptionalFileTypes() {
+ AutomationNodeDefinition definition = find(AutomationConstants.NODE_STORAGE_LIST);
+ assertNotNull(definition);
+ Map> fieldsByKey = definition.configFields().stream()
+ .collect(java.util.stream.Collectors.toMap(AutomationNodeDefinition.ConfigField::key,
+ AutomationNodeDefinition.ConfigField::toMap));
+ assertEquals("string[]", fieldsByKey.get("extensions").get("type"));
+ assertEquals(List.of(), definition.defaultConfig().get("extensions"));
+ }
+
+ @Test
+ void dataExtractAdvertisesOneGenericJsonXmlContract() {
+ AutomationNodeDefinition definition = find(AutomationConstants.NODE_DATA_EXTRACT);
+ assertNotNull(definition);
+ assertEquals("data", definition.toMap().get("category"));
+ Map> fieldsByKey = definition.configFields().stream()
+ .collect(java.util.stream.Collectors.toMap(AutomationNodeDefinition.ConfigField::key,
+ AutomationNodeDefinition.ConfigField::toMap));
+ assertEquals("textarea", fieldsByKey.get(AutomationConstants.CONFIG_SOURCE).get("type"));
+ assertEquals("string", fieldsByKey.get(AutomationConstants.CONFIG_PATH).get("type"));
+ assertEquals("auto", definition.defaultConfig().get(AutomationConstants.CONFIG_FORMAT));
+ assertEquals(false, definition.nodeType().supportsCustomCode());
+ }
+
@Test
void optionalEngineParametersAreBackendOwned() {
Map> uploadFields = find(AutomationConstants.NODE_STORAGE_UPLOAD).configFields()
@@ -142,6 +167,27 @@ void jevDecisionAdvertisesTypeSafeRoutingConfiguration() {
definition.defaultConfig().get(AutomationConstants.CONFIG_QUESTION_TYPE));
}
+ @Test
+ void loopAdvertisesBoundedSequentialConfiguration() {
+ AutomationNodeDefinition definition = find(AutomationConstants.NODE_CONTROL_LOOP);
+ assertNotNull(definition);
+ assertEquals(AutomationConstants.LOOP_MODE_FOR_EACH,
+ definition.defaultConfig().get(AutomationConstants.CONFIG_LOOP_MODE));
+ Map> fieldsByKey = definition.configFields().stream()
+ .collect(java.util.stream.Collectors.toMap(AutomationNodeDefinition.ConfigField::key,
+ AutomationNodeDefinition.ConfigField::toMap));
+ assertEquals(AutomationConstants.LOOP_MAX_ITERATIONS,
+ fieldsByKey.get(AutomationConstants.CONFIG_LOOP_MAX_ITERATIONS).get("maximum"));
+ assertEquals(1, fieldsByKey.get(AutomationConstants.CONFIG_LOOP_BATCH_SIZE).get("minimum"));
+ assertEquals(1, fieldsByKey.get(AutomationConstants.CONFIG_LOOP_COUNT).get("minimum"));
+ assertEquals(false, fieldsByKey.get(AutomationConstants.CONFIG_LOOP_ITEMS).get("required"));
+ assertEquals(false, fieldsByKey.get(AutomationConstants.CONFIG_LOOP_COUNT).get("required"));
+ assertNotNull(fieldsByKey.get(AutomationConstants.CONFIG_LOOP_CONDITION));
+ assertEquals(AutomationNodeDefinition.OutputFieldType.OBJECT_LIST,
+ definition.outputFields().stream().filter(field -> "results".equals(field.key())).findFirst().orElseThrow()
+ .type());
+ }
+
/** Writes need edit rights on the engine; a read only needs view. */
@Test
void writeNodesRequireEditPermission() {
diff --git a/test/prerna/reactor/automation/definition/AutomationSourceRendererUnitTests.java b/test/prerna/reactor/automation/definition/AutomationSourceRendererUnitTests.java
index 58875b9d65..e6eeabee47 100644
--- a/test/prerna/reactor/automation/definition/AutomationSourceRendererUnitTests.java
+++ b/test/prerna/reactor/automation/definition/AutomationSourceRendererUnitTests.java
@@ -118,12 +118,48 @@ void storageDownloadReturnsTransferDetailsWithoutReimplementingPathRules() {
assertTrue(source.contains("_pixel_value(\"options\", \"regex\")"));
assertTrue(source.contains("\"files\": files"));
assertTrue(source.contains("\"filePath\": files[0] if len(files) == 1 else None"));
+ assertTrue(source.contains("if not files:"));
+ assertTrue(source.contains("No file was downloaded from storage path"));
assertTrue(source.contains("or \"/\""));
assertFalse(source.contains("pathlib"));
assertFalse(source.contains(".replace("));
assertFalse(source.contains("return storage.copyToLocal("));
}
+ @Test
+ void storageListCanFilterFileTypesWithoutChangingTheStorageEngine() {
+ Map config = new LinkedHashMap<>();
+ config.put("engineId", "storage-1");
+ config.put("path", "incoming");
+ config.put("extensions", java.util.List.of("pdf", ".png"));
+
+ String source = AutomationSourceRenderer
+ .renderNode(node(AutomationConstants.NODE_STORAGE_LIST, config));
+
+ assertTrue(source.contains("details = storage.listDetails(scope.resolve(STORAGE_PATH))"));
+ assertTrue(source.contains("item.get(\"Path\")"));
+ assertTrue(source.contains("path.rstrip(\"/\").lower().endswith(suffixes)"));
+ assertTrue(source.contains("FILE_TYPES = [\"pdf\",\".png\"]"));
+ }
+
+ @Test
+ void dataExtractReadsOnlyFromTheCurrentInsightWorkspace() {
+ Map config = new LinkedHashMap<>();
+ config.put(AutomationConstants.CONFIG_SOURCE, "${download.filePath}");
+ config.put(AutomationConstants.CONFIG_PATH, "orders[0].id");
+ config.put(AutomationConstants.CONFIG_FORMAT, "json");
+ config.put(AutomationConstants.CONFIG_MISSING_VALUE, "missing");
+ config.put(AutomationConstants.CONFIG_NULL_VALUE, "null");
+
+ String source = AutomationSourceRenderer.renderNode(node(AutomationConstants.NODE_DATA_EXTRACT, config));
+
+ assertTrue(source.contains("GetInsightAssetsBase64("));
+ assertTrue(source.contains("extract_data_element("));
+ assertTrue(source.contains("source=scope.resolve(SOURCE)"));
+ assertFalse(source.contains("from semoss_automation_runtime import"));
+ assertFalse(source.contains("open("));
+ }
+
@Test
void visionAcceptsOneOrManyMediaPathsWithoutNestingThem() {
Map config = new LinkedHashMap<>();
@@ -197,7 +233,8 @@ void customPythonStartsWithAnEditableRunScopeTemplate() {
@Test
void everyNodeSourceResolvesThroughScope() {
for (AutomationNodeType type : AutomationNodeType.values()) {
- if (type == AutomationNodeType.CONTROL_IF || type == AutomationNodeType.CONTROL_JEV) {
+ if (type == AutomationNodeType.CONTROL_IF || type == AutomationNodeType.CONTROL_JEV
+ || type == AutomationNodeType.CONTROL_LOOP) {
continue;
}
String source = AutomationSourceRenderer.renderNode(node(type.getType(), databaseConfig()));
diff --git a/test/prerna/reactor/automation/run/AutomationRunExecutionServiceUnitTests.java b/test/prerna/reactor/automation/run/AutomationRunExecutionServiceUnitTests.java
index 6f27f4d5c4..c0cf114247 100644
--- a/test/prerna/reactor/automation/run/AutomationRunExecutionServiceUnitTests.java
+++ b/test/prerna/reactor/automation/run/AutomationRunExecutionServiceUnitTests.java
@@ -29,7 +29,9 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -114,4 +116,47 @@ void exposesRunInsightOnlyWhileItRemainsInTheInsightStore() {
assertNull(AutomationRunExecutionService.getAvailableExecutionInsightId(runId));
}
+
+ @Test
+ void resolvesLoopItemsWithoutCoercingNativeValues() {
+ List items = List.of(Map.of("id", 1), Map.of("id", 2));
+ assertEquals(items, AutomationRunExecutionService.loopItems("${records}", Map.of("records", items), "loop"));
+ assertEquals(items, AutomationRunExecutionService.loopItems("${query.data.rows}",
+ Map.of("query", Map.of("data", Map.of("rows", items))), "loop"));
+ assertEquals(List.of("a", "b"),
+ AutomationRunExecutionService.loopItems(new String[] { "a", "b" }, Map.of(), "loop"));
+ }
+
+ @Test
+ void rejectsMissingOrNonCollectionLoopItems() {
+ assertThrows(IllegalArgumentException.class,
+ () -> AutomationRunExecutionService.loopItems("${missing}", Map.of(), "loop"));
+ assertThrows(IllegalArgumentException.class,
+ () -> AutomationRunExecutionService.loopItems(42, Map.of(), "loop"));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void groupsMaterializedLoopHistoryUnderItsParentNode() {
+ Map parent = new LinkedHashMap<>();
+ parent.put(AutomationConstants.RUN_ID, "run");
+ parent.put(AutomationConstants.NODE_ID, "loop");
+ parent.put(AutomationConstants.NODE_LABEL, "Loop");
+ parent.put(AutomationConstants.STATUS, AutomationConstants.NODE_STATUS_SUCCESS);
+ Map child = new LinkedHashMap<>();
+ child.put(AutomationConstants.RUN_ID, "run");
+ child.put(AutomationConstants.NODE_ID, "execution-id");
+ child.put(AutomationConstants.SOURCE_NODE_ID, "body-python");
+ child.put(AutomationConstants.PARENT_NODE_ID, "loop");
+ child.put(AutomationConstants.ITERATION_INDEX, 0);
+ child.put(AutomationConstants.NODE_LABEL, "Python");
+ child.put(AutomationConstants.STATUS, AutomationConstants.NODE_STATUS_SUCCESS);
+
+ List> results = AutomationRunStore.buildNodeResults(List.of(parent, child));
+
+ assertEquals(1, results.size());
+ List> iterations = (List>) results.get(0).get("iterations");
+ List> nodes = (List>) iterations.get(0).get("nodeResults");
+ assertEquals("body-python", nodes.get(0).get(AutomationConstants.NODE_ID));
+ }
}
diff --git a/test/prerna/reactor/automation/utils/AutomationRuntimeUtilsUnitTests.java b/test/prerna/reactor/automation/utils/AutomationRuntimeUtilsUnitTests.java
index d6cd00ff13..3fcc2e6d36 100644
--- a/test/prerna/reactor/automation/utils/AutomationRuntimeUtilsUnitTests.java
+++ b/test/prerna/reactor/automation/utils/AutomationRuntimeUtilsUnitTests.java
@@ -126,6 +126,17 @@ void boundedJsonRejectsSelfReferencingValues() {
() -> AutomationRuntimeUtils.toBoundedRuntimeJson(cyclic, 4096, "Automation run scope"));
}
+ @Test
+ void boundedJsonAcceptsRepeatedAcyclicContainers() {
+ Map row = Map.of("id", 7);
+ Map value = new LinkedHashMap<>();
+ value.put("rows", List.of(row));
+ value.put("current", row);
+
+ assertEquals("{\"rows\":[{\"id\":7}],\"current\":{\"id\":7}}",
+ AutomationRuntimeUtils.toBoundedRuntimeJson(value, 4096, "Automation run scope"));
+ }
+
@Test
void boundedJsonRejectsValuesNestedTooDeeply() {
List deepest = new ArrayList<>();