Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions apps/web/app/components/ui/RichVariableInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,23 @@ export function getNodeColorClass(nodeId: string): string {
export function parseValueToHtml(rawValue: string, availableNodes: AvailableNode[]): string {
if (!rawValue) return "";

return rawValue.replace(/\{\{([^.]+)\.([^}]+)\}\}/g, (match, nodeId, path) => {
// 1. Sanitize the raw input to prevent Cross-Site Scripting (XSS)
const escapeHtml = (str: string) => {
return str
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
const sanitizedValue = escapeHtml(rawValue);
// 2. Parse the sanitized value to inject the visual pills
return sanitizedValue.replace(/\{\{([^.]+)\.([^}]+)\}\}/g, (match, nodeId, path) => {
const node = availableNodes.find(n => n.id === nodeId);
const displayName = node ? node.name : "Unknown Node";
const displayName = node ? escapeHtml(node.name) : "Unknown Node";
const colorClass = getNodeColorClass(nodeId);

return `<span contenteditable="false" class="inline-flex items-center px-1.5 py-0.5 mx-1 rounded text-[10px] border align-middle font-mono select-all cursor-default ${colorClass}" data-id="${nodeId}" data-path="${path}">${displayName} &gt; ${path}</span>`;
// 3. IMPORTANT: Added the "pill" class at the start of the class list
return `<span contenteditable="false" class="pill inline-flex items-center px-1.5 py-0.5 mx-1 rounded text-[10px] border align-middle font-mono select-all cursor-default ${colorClass}" data-id="${nodeId}" data-path="${path}">${displayName} &gt; ${path}</span>`;
Comment on lines 51 to +55
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}

Expand Down
78 changes: 43 additions & 35 deletions apps/worker/src/engine/executor.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { prismaClient } from "@repo/db/client";
// import { register } from "./registory.js";
import { ExecutionRegister } from "@repo/nodes";
import {
resolveConfigVariables,
buildInterpolationContext,
InterpolationContext
import {
resolveConfigVariables,
buildInterpolationContext,
InterpolationContext
} from "@repo/common/zod";
Comment on lines +4 to 8

// Track node outputs during workflow execution for variable resolution
interface NodeExecutionOutput {
nodeName: string;
nodeId?: string;
outputData: any;
}

Expand All @@ -29,8 +30,8 @@ interface LoopExecutionResult {
*/
function isSpreadsheetInput(data: any): boolean {
return data?.rows && Array.isArray(data.rows) &&
data?.columns && typeof data.columns === 'object' &&
data?.dataStartIndex !== undefined;
data?.columns && typeof data.columns === 'object' &&
data?.dataStartIndex !== undefined;
}

/**
Expand Down Expand Up @@ -61,10 +62,10 @@ export async function executeWorkflow(
},
});
let currentInputData = data?.metadata;

// Collect outputs from all executed nodes for variable interpolation
const executedNodeOutputs: NodeExecutionOutput[] = [];

if (!data) {
console.log(`No workflow execution found for id ${workflowExecutionId}`);
return;
Expand All @@ -86,7 +87,7 @@ export async function executeWorkflow(
for (const node of nodes) {
console.log(`${node.name}, ${node.stage}, ${node.id}th - started Execution`);
const nodeExecution = await prismaClient.nodeExecution.create({
data:{
data: {
nodeId: node.id,
workflowExecId: workflowExecutionId,
status: "Start",
Expand All @@ -95,21 +96,24 @@ export async function executeWorkflow(
}
})
const nodeType = node.AvailableNode.type;

// Create mutable copy of config
let nodeConfig = { ...(node.config as Record<string, any>) };

// Build interpolation context from all previously executed nodes
const interpolationContext = buildInterpolationContext(executedNodeOutputs);
for (const out of executedNodeOutputs) {
if (out.nodeId) interpolationContext[out.nodeId] = out.outputData;
}
Comment on lines +105 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 8 \
  'buildInterpolationContext|normalizedName|resolveVariable|resolveConfigVariables|nodeId|node\.name|model .*Node|`@default`\(cuid\)|`@default`\(uuid\)' \
  --glob '*.ts' --glob '*.tsx' --glob '*.prisma' . || true

Repository: Dev-Pross/BuildFlow

Length of output: 50376


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== interpolation implementation =="
sed -n '50,115p' packages/common/src/interpolation.ts

echo "== prisma schema Node and relation fields =="
sed -n '67,95p' packages/db/prisma/schema.prisma
sed -n '113,127p' packages/db/prisma/schema.prisma

echo "== workflow/node creation snippets =="
rg -n -C 5 'workflow\.nodes|nodes:|AvailableNodeID|NodeId|nodeId|name.*\s*:|name\s*:' --glob '*.ts' --glob '*.tsx' --glob '*.prisma' . || true

echo "== focused UUID prefix checks via static patterns =="
rg -n 'uuid\\(\\)|node\\.id|nodeId|node.*name|AvailableNode#|AvailableNodeID|createMany\\(|create\\(' --glob '*.ts' --glob '*.tsx' --glob '*.prisma' . || true

Repository: Dev-Pross/BuildFlow

Length of output: 50376


Prevent node IDs from overwriting normalized node-name keys.

buildInterpolationContext maps sanitized node names, then the execution loop writes raw node.id keys into the same context object. Since node IDs are UUIDs and node names can normalize to any lowercase snake_case string, interpolationContext[out.nodeId] = out.outputData can silently replace a name-based variable source. Use a separate ID namespace or block/emit a collision before insertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/engine/executor.ts` around lines 105 - 107, Update
buildInterpolationContext and its executedNodeOutputs loop so raw node IDs
cannot overwrite normalized node-name keys; use a distinct ID namespace for
ID-based entries, or detect and reject/report collisions before insertion, while
preserving name-based interpolation values.

console.log(`[Interpolation] Before: ${JSON.stringify(interpolationContext)}`);
// Resolve any {{variable}} references in the config
console.log(`[nodeConfig] Before: ${JSON.stringify(nodeConfig)}`);
nodeConfig = resolveConfigVariables(nodeConfig, interpolationContext);
console.log(`[Interpolation] After: ${JSON.stringify(nodeConfig)}`);

// NOTE: Removed legacy body concatenation that appended raw JSON to email body.
// Variables should be resolved via the {{interpolation}} system instead.
if(!node.CredentialsID){
if (!node.CredentialsID) {
await prismaClient.workflowExecution.update({
where: { id: workflowExecutionId },
data: {
Expand All @@ -120,8 +124,8 @@ export async function executeWorkflow(
});

await prismaClient.nodeExecution.update({
where: {id: nodeExecution.id},
data:{
where: { id: nodeExecution.id },
data: {
status: "Failed",
error: "Credential id not found",
completedAt: new Date()
Expand All @@ -131,9 +135,9 @@ export async function executeWorkflow(
}

// Check if we need to loop (inputData is spreadsheet + config has column variables)
const shouldLoop = isSpreadsheetInput(currentInputData) &&
const shouldLoop = isSpreadsheetInput(currentInputData) &&
JSON.stringify(node.config).includes('{{');

let execute: { success: boolean; output?: any; error?: string };

if (shouldLoop) {
Expand All @@ -156,10 +160,10 @@ export async function executeWorkflow(

for (let rowIdx = startIdx; rowIdx < spreadsheet.rows.length; rowIdx++) {
loopResult.totalProcessed++;

// Set _currentRowIndex on the spreadsheet data for column resolution
const rowContext = { ...spreadsheet, _currentRowIndex: rowIdx };

// Rebuild interpolation context with current row index
const loopOutputs = executedNodeOutputs.map(o => {
if (o.outputData === currentInputData) {
Expand All @@ -168,11 +172,14 @@ export async function executeWorkflow(
return o;
});
const loopInterpolationCtx = buildInterpolationContext(loopOutputs);

for (const out of loopOutputs) {
if (out.nodeId) loopInterpolationCtx[out.nodeId] = out.outputData;
}

// Re-resolve config with current row
const originalConfig = { ...(node.config as Record<string, any>) };
const resolvedRowConfig = resolveConfigVariables(originalConfig, loopInterpolationCtx);

console.log(`[Loop] Row ${rowIdx}: resolved config = ${JSON.stringify(resolvedRowConfig)}`);

// Skip rows with empty/null required fields (e.g. empty email recipient)
Expand All @@ -195,7 +202,7 @@ export async function executeWorkflow(
console.log(`[Loop] Row ${rowIdx} SKIPPED: ${skipReasons.join('; ')}`);
continue;
}

const rowCtx = {
userId: data.workflow.userId,
credentialId: node.CredentialsID,
Expand All @@ -206,7 +213,7 @@ export async function executeWorkflow(
// Retry logic: up to 3 attempts per row
let rowSuccess = false;
let lastError: string | undefined;

for (let attempt = 1; attempt <= 3; attempt++) {
try {
const rowResult = await ExecutionRegister.execute(nodeType, rowCtx);
Expand All @@ -223,7 +230,7 @@ export async function executeWorkflow(
lastError = err instanceof Error ? err.message : 'Unknown error';
console.log(`[Loop] Row ${rowIdx} attempt ${attempt} threw: ${lastError}`);
}

if (attempt < 3) {
await delay(200 * attempt); // Backoff: 200ms, 400ms
}
Expand All @@ -249,11 +256,11 @@ export async function executeWorkflow(
execute = {
success: !hasFailures,
output: loopResult,
error: hasFailures
error: hasFailures
? JSON.stringify({
summary: `${loopResult.failed}/${loopResult.totalProcessed} rows failed`,
failures: loopResult.failures
})
summary: `${loopResult.failed}/${loopResult.totalProcessed} rows failed`,
failures: loopResult.failures
})
: undefined
};
} else {
Expand Down Expand Up @@ -281,9 +288,9 @@ export async function executeWorkflow(
});

await prismaClient.nodeExecution.update({
where: {id: nodeExecution.id},
data:{
status: "Failed" ,
where: { id: nodeExecution.id },
data: {
status: "Failed",
error: execute.error,
outputData: isPartialFailure ? execute.output : undefined,
completedAt: new Date()
Expand All @@ -292,23 +299,24 @@ export async function executeWorkflow(
return;
}
await prismaClient.nodeExecution.update({
where: {id: nodeExecution.id},
where: { id: nodeExecution.id },
data: {
completedAt: new Date(),
outputData: execute.output,
status: "Completed"
}
})

// Store this node's output for variable resolution in subsequent nodes
executedNodeOutputs.push({
nodeName: node.name,
nodeId: node.id,
outputData: execute.output
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Comment on lines 310 to 315
console.log(`[Interpolation] Added ${node.name} output to context. Total nodes in context: ${executedNodeOutputs.length}`);

currentInputData = execute.output;

console.log("output: ", JSON.stringify(execute));
}
const updatedStatus = await prismaClient.workflowExecution.update({
Expand Down