-
Notifications
You must be signed in to change notification settings - Fork 0
Add Data Transform and Loop nodes with Workflow Analytics Profiler #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -432,6 +432,38 @@ export function generatePython( | |
| `# ${c.content ? c.content.replace(/\n/g, "\n# ") : "(empty note)"}`, | ||
| `return "next"`, | ||
| ].join("\n"); | ||
| case "transform": { | ||
| const op = (c.operation || "json_map").toLowerCase(); | ||
| const src = pyStr(c.source_path || "state"); | ||
| const tgt = pyStr(c.target_key || "transformed_result"); | ||
| const param = pyStr(c.param || ""); | ||
| return [ | ||
| `# Data Transform op=${op}`, | ||
| `source_val = state.get(${src}, state.last)`, | ||
| `param_val = interpolate(${param}, state)`, | ||
| `result = {"op": ${pyStr(op)}, "source": source_val, "param": param_val}`, | ||
| `state.set(${tgt}, result)`, | ||
|
Comment on lines
+444
to
+445
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Implement the selected transform operation. Both branches store Also applies to: 669-670 🤖 Prompt for AI Agents |
||
| `state.last = result`, | ||
| `return "next"`, | ||
| ].join("\n"); | ||
| } | ||
| case "loop": { | ||
| const itemsPath = pyStr(c.items_path || "state.items"); | ||
|
|
||
| const outputKey = pyStr(c.output_key || "loop_results"); | ||
| const tmpl = pyStr(c.transform_template || ""); | ||
| const maxIter = parseInt(c.max_iterations || "50", 10) || 50; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Clamp invalid
Also applies to: 679-679 🤖 Prompt for AI Agents |
||
| return [ | ||
| `# Loop Iterator items=${itemsPath}`, | ||
| `raw_items = state.get(${itemsPath}, [])`, | ||
| `items_list = raw_items if isinstance(raw_items, list) else [raw_items] if raw_items else []`, | ||
| `mapped_results = []`, | ||
| `for idx, item in enumerate(items_list[:${maxIter}]):`, | ||
| ` mapped_results.append(interpolate(${tmpl}, state) if ${tmpl} else item)`, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Pass the current loop item to template interpolation. The loop creates Also applies to: 683-683 🤖 Prompt for AI Agents |
||
| `state.set(${outputKey}, mapped_results)`, | ||
| `state.last = mapped_results`, | ||
| `return "next"`, | ||
| ].join("\n"); | ||
| } | ||
| default: { | ||
| const _exhaustive: never = d.kind as never; | ||
| return `return "next" # unknown kind ${_exhaustive}`; | ||
|
|
@@ -626,6 +658,34 @@ export function generateJavaScript( | |
| `// ${c.content ? c.content.replace(/\n/g, "\n// ") : "(empty note)"}`, | ||
| `return "next";`, | ||
| ].join("\n"); | ||
| case "transform": { | ||
| const op = (c.operation || "json_map").toLowerCase(); | ||
| const src = JSON.stringify(c.source_path || "state"); | ||
| const tgt = JSON.stringify(c.target_key || "transformed_result"); | ||
| const param = JSON.stringify(c.param || ""); | ||
| return [ | ||
| `const sourceVal = state.get(${src}) ?? state.last;`, | ||
| `const paramVal = interpolate(${param}, state);`, | ||
| `const result = { op: "${op}", source: sourceVal, param: paramVal };`, | ||
| `state.set(${tgt}, result);`, | ||
| `state.last = result;`, | ||
| `return "next";`, | ||
| ].join("\n"); | ||
| } | ||
| case "loop": { | ||
| const itemsPath = JSON.stringify(c.items_path || "state.items"); | ||
|
|
||
| const outputKey = JSON.stringify(c.output_key || "loop_results"); | ||
| const tmpl = JSON.stringify(c.transform_template || ""); | ||
| const maxIter = parseInt(c.max_iterations || "50", 10) || 50; | ||
| return [ | ||
| `const rawItems = state.get(${itemsPath}) ?? [];`, | ||
| `const itemsList = Array.isArray(rawItems) ? rawItems : [rawItems];`, | ||
| `const mapped = itemsList.slice(0, ${maxIter}).map(item => interpolate(${tmpl}, state) || item);`, | ||
| `state.set(${outputKey}, mapped);`, | ||
| `state.last = mapped;`, | ||
| `return "next";`, | ||
| ].join("\n"); | ||
| } | ||
| default: | ||
| return `return "next";`; | ||
| } | ||
|
|
@@ -677,4 +737,4 @@ export function generateCode( | |
| } | ||
|
|
||
| // also export the kind set for sanity | ||
| export const ALL_KINDS: AgentNodeKind[] = ["trigger","llm","tool","router","subagent","memory","human","sink","http","script","note"]; | ||
| export const ALL_KINDS: AgentNodeKind[] = ["trigger","llm","tool","router","subagent","memory","human","sink","http","script","note","transform","loop"]; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -96,6 +96,22 @@ function getPath(obj: Record<string, unknown>, path: string): unknown { | |||||||||||||||||||||||||||||||||
| }, obj); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * Recursively flattens a nested object structure into a single-level object with dot-separated keys. | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
| function flattenObject(obj: Record<string, unknown>, prefix = ""): Record<string, unknown> { | ||||||||||||||||||||||||||||||||||
| const result: Record<string, unknown> = {}; | ||||||||||||||||||||||||||||||||||
| for (const [key, value] of Object.entries(obj)) { | ||||||||||||||||||||||||||||||||||
| const newKey = prefix ? `${prefix}.${key}` : key; | ||||||||||||||||||||||||||||||||||
| if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0) { | ||||||||||||||||||||||||||||||||||
| Object.assign(result, flattenObject(value as Record<string, unknown>, newKey)); | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| result[newKey] = value; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * Evaluates the output state of the current node to select the matching outgoing edge. | ||||||||||||||||||||||||||||||||||
| * Prioritizes on_error edges if an error occurred, router conditions, tool_results, | ||||||||||||||||||||||||||||||||||
|
|
@@ -478,6 +494,149 @@ export async function runNode( | |||||||||||||||||||||||||||||||||
| annotationOnly: true, | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| case "transform": { | ||||||||||||||||||||||||||||||||||
| const op = (cfg.operation || "json_map").toLowerCase(); | ||||||||||||||||||||||||||||||||||
| const sourcePath = (cfg.source_path || "state").trim(); | ||||||||||||||||||||||||||||||||||
| const targetKey = (cfg.target_key || "transformed_result").trim(); | ||||||||||||||||||||||||||||||||||
| const param = cfg.param || ""; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Resolve source value | ||||||||||||||||||||||||||||||||||
| let sourceVal: unknown; | ||||||||||||||||||||||||||||||||||
| if (!sourcePath || sourcePath === "state") { | ||||||||||||||||||||||||||||||||||
| sourceVal = state; | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| const cleanPath = sourcePath.startsWith("state.") ? sourcePath.slice(6) : sourcePath; | ||||||||||||||||||||||||||||||||||
| sourceVal = getPath(state, cleanPath) ?? state.last_output; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| let result: unknown; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (op === "pick_fields") { | ||||||||||||||||||||||||||||||||||
| const fields = param.split(",").map((f) => f.trim()).filter(Boolean); | ||||||||||||||||||||||||||||||||||
| if (typeof sourceVal === "object" && sourceVal !== null && !Array.isArray(sourceVal)) { | ||||||||||||||||||||||||||||||||||
| const picked: Record<string, unknown> = {}; | ||||||||||||||||||||||||||||||||||
| const sourceObj = sourceVal as Record<string, unknown>; | ||||||||||||||||||||||||||||||||||
| fields.forEach((f) => { | ||||||||||||||||||||||||||||||||||
| if (f in sourceObj) picked[f] = sourceObj[f]; | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| result = picked; | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| result = sourceVal; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } else if (op === "template_string") { | ||||||||||||||||||||||||||||||||||
| result = interpolate(param, state, globalsList, secretsList); | ||||||||||||||||||||||||||||||||||
| } else if (op === "set_keys") { | ||||||||||||||||||||||||||||||||||
| let patch: Record<string, unknown> = {}; | ||||||||||||||||||||||||||||||||||
| if (param.trim()) { | ||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| const interpolatedParam = interpolate(param, state, globalsList, secretsList); | ||||||||||||||||||||||||||||||||||
| patch = JSON.parse(interpolatedParam); | ||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||
| patch = { value: interpolate(param, state, globalsList, secretsList) }; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if (typeof sourceVal === "object" && sourceVal !== null && !Array.isArray(sourceVal)) { | ||||||||||||||||||||||||||||||||||
| result = { ...(sourceVal as Record<string, unknown>), ...patch }; | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| result = { ...patch }; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } else if (op === "flatten_object") { | ||||||||||||||||||||||||||||||||||
| if (typeof sourceVal === "object" && sourceVal !== null && !Array.isArray(sourceVal)) { | ||||||||||||||||||||||||||||||||||
| result = flattenObject(sourceVal as Record<string, unknown>); | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| result = sourceVal; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| // Default json_map | ||||||||||||||||||||||||||||||||||
| if (param.trim()) { | ||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| const interpolatedParam = interpolate(param, state, globalsList, secretsList); | ||||||||||||||||||||||||||||||||||
| const mapping = JSON.parse(interpolatedParam); | ||||||||||||||||||||||||||||||||||
| if (typeof mapping === "object" && mapping !== null && !Array.isArray(mapping)) { | ||||||||||||||||||||||||||||||||||
| const mappedObj: Record<string, unknown> = {}; | ||||||||||||||||||||||||||||||||||
| const sourceObj = (typeof sourceVal === "object" && sourceVal !== null ? sourceVal : state) as Record<string, unknown>; | ||||||||||||||||||||||||||||||||||
| Object.entries(mapping as Record<string, string>).forEach(([newKey, origPath]) => { | ||||||||||||||||||||||||||||||||||
| const cleanOrigPath = String(origPath).startsWith("state.") ? String(origPath).slice(6) : String(origPath); | ||||||||||||||||||||||||||||||||||
| mappedObj[newKey] = getPath(sourceObj, cleanOrigPath) ?? getPath(state, cleanOrigPath) ?? origPath; | ||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Missing mapping paths produce the path string as data.
Return 🐛 Proposed fix- mappedObj[newKey] = getPath(sourceObj, cleanOrigPath) ?? getPath(state, cleanOrigPath) ?? origPath;
+ const resolved = getPath(sourceObj, cleanOrigPath) ?? getPath(state, cleanOrigPath);
+ mappedObj[newKey] = resolved === undefined ? null : resolved;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| result = mappedObj; | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| result = mapping; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||
| result = interpolate(param, state, globalsList, secretsList); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| result = sourceVal; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| state[targetKey] = result; | ||||||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| case "loop": { | ||||||||||||||||||||||||||||||||||
| const itemsPath = (cfg.items_path || "state.items").trim(); | ||||||||||||||||||||||||||||||||||
| const itemVar = (cfg.item_var || "item").trim(); | ||||||||||||||||||||||||||||||||||
| const outputKey = (cfg.output_key || "loop_results").trim(); | ||||||||||||||||||||||||||||||||||
| const template = cfg.transform_template || ""; | ||||||||||||||||||||||||||||||||||
| const maxIter = parseIntOr(cfg.max_iterations, 50); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+578
to
+582
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const cleanPath = itemsPath.startsWith("state.") ? itemsPath.slice(6) : itemsPath; | ||||||||||||||||||||||||||||||||||
| const rawItems = getPath(state, cleanPath) ?? (cleanPath in state ? state[cleanPath] : undefined); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| let itemsArray: unknown[] = []; | ||||||||||||||||||||||||||||||||||
| if (Array.isArray(rawItems)) { | ||||||||||||||||||||||||||||||||||
| itemsArray = rawItems; | ||||||||||||||||||||||||||||||||||
| } else if (rawItems !== undefined && rawItems !== null) { | ||||||||||||||||||||||||||||||||||
| itemsArray = [rawItems]; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const truncatedItems = itemsArray.slice(0, maxIter); | ||||||||||||||||||||||||||||||||||
| const mappedResults: unknown[] = []; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| for (const item of truncatedItems) { | ||||||||||||||||||||||||||||||||||
| const itemScope: Record<string, unknown> = { ...state, [itemVar]: item }; | ||||||||||||||||||||||||||||||||||
| if (!template.trim()) { | ||||||||||||||||||||||||||||||||||
| mappedResults.push(item); | ||||||||||||||||||||||||||||||||||
| } else if (template.includes("{{")) { | ||||||||||||||||||||||||||||||||||
| let interpolated = template; | ||||||||||||||||||||||||||||||||||
| if (globalsList) { | ||||||||||||||||||||||||||||||||||
| globalsList.forEach((g) => { | ||||||||||||||||||||||||||||||||||
| interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*global\\.${g.key}\\s*\\}\\}`, "g"), g.value); | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if (secretsList) { | ||||||||||||||||||||||||||||||||||
| secretsList.forEach((s) => { | ||||||||||||||||||||||||||||||||||
| interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*secret\\.${s.key}\\s*\\}\\}`, "g"), s.value); | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*${itemVar}\\.([\\w.]+)\\s*\\}\\}`, "g"), (_m, prop) => { | ||||||||||||||||||||||||||||||||||
| if (typeof item === "object" && item !== null) { | ||||||||||||||||||||||||||||||||||
| const val = getPath(item as Record<string, unknown>, String(prop)); | ||||||||||||||||||||||||||||||||||
| return val === undefined ? "" : typeof val === "string" ? val : JSON.stringify(val); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| return ""; | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*${itemVar}\\s*\\}\\}`, "g"), () => { | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+613
to
+620
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Escape
Escape the value before interpolation. Line 625 also compares against the raw value, so keep that path consistent with the escaped pattern. 🛡️ Proposed fix const itemVar = (cfg.item_var || "item").trim();
+ const itemVarRe = itemVar.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");- interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*${itemVar}\\.([\\w.]+)\\s*\\}\\}`, "g"), (_m, prop) => {
+ interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*${itemVarRe}\\.([\\w.]+)\\s*\\}\\}`, "g"), (_m, prop) => {
if (typeof item === "object" && item !== null) {
const val = getPath(item as Record<string, unknown>, String(prop));
return val === undefined ? "" : typeof val === "string" ? val : JSON.stringify(val);
}
return "";
});
- interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*${itemVar}\\s*\\}\\}`, "g"), () => {
+ interpolated = interpolated.replace(new RegExp(`\\{\\{\\s*${itemVarRe}\\s*\\}\\}`, "g"), () => {
return typeof item === "string" ? item : JSON.stringify(item);
});📝 Committable suggestion
Suggested change
🧰 Tools🪛 ast-grep (0.45.2)[warning] 619-619: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns. (regexp-from-variable) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||||||||||||||||||||||||||||||||||
| return typeof item === "string" ? item : JSON.stringify(item); | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+613
to
+622
|
||||||||||||||||||||||||||||||||||
| interpolated = interpolate(interpolated, state, globalsList, secretsList); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| mappedResults.push(interpolated); | ||||||||||||||||||||||||||||||||||
| } else if (template.startsWith(`${itemVar}.`)) { | ||||||||||||||||||||||||||||||||||
| const propPath = template.slice(itemVar.length + 1); | ||||||||||||||||||||||||||||||||||
| if (typeof item === "object" && item !== null) { | ||||||||||||||||||||||||||||||||||
| mappedResults.push(getPath(item as Record<string, unknown>, propPath) ?? null); | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| mappedResults.push(null); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| mappedResults.push(template); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| state[outputKey] = mappedResults; | ||||||||||||||||||||||||||||||||||
| return { count: mappedResults.length, items: mappedResults }; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| default: | ||||||||||||||||||||||||||||||||||
| return { kind: node.data.kind, note: "no executor" }; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Resolve
state.*paths in generated code.The generated
State.getmethods perform exact key lookups, but these branches read paths such as"state.data"and"state.items". The configured paths used byfrontend/src/test/transformAndLoopAndAnalytics.test.tsxtherefore miss normal state values. The transform falls back tostate.last, and the loop receives an empty list. Use the same path resolver as the runtime in both generated backends.Also applies to: 457-457, 667-667, 681-681
🤖 Prompt for AI Agents