Переглянути джерело

Improve workflow system: IMAP nodes, UI enhancements, and expression evaluation

IMAP Improvements:
- Add fetchContent option to IMAP Trigger for full email body retrieval
- Improve IMAP Search with better date filtering
- Enhance IMAP client with proper content fetching

UI Enhancements:
- Fix NodeConfigModal to handle integer type fields
- Improve ExecutionListPanel with better status display
- Enhance ExecutionResultsPanel for better output visualization
- Update WorkflowNode styling and execution status display

Expression Evaluation:
- Fix IF condition to handle evaluated boolean/number values directly
- Improve Loop node expression handling
- Add path simplification for loop variables (data.loop.item -> item)

Backend:
- Add workflow execution endpoints
- Improve node store with better metadata handling
- Add scheduler infrastructure (placeholder)
fszontagh 6 місяців тому
батько
коміт
c45a802c26

+ 1 - 0
CMakeLists.txt

@@ -180,6 +180,7 @@ add_executable(smartbotic-webserver
     src/webserver/runners/runner_registry.cpp
     src/webserver/runners/load_balancer.cpp
     src/webserver/nodes/node_store.cpp
+    src/webserver/scheduler/workflow_scheduler.cpp
     src/webserver/grpc/node_sync_service.cpp
     src/webserver/grpc/credential_service.cpp
     src/webserver/api/auth_controller.cpp

+ 25 - 14
nodes/core/if-condition.js

@@ -2,7 +2,7 @@
  * @node if-condition
  * @name IF Condition
  * @category flow-control
- * @version 2.0.0
+ * @version 2.2.0
  * @description Conditional branching with TRUE/FALSE output paths
  * @icon git-branch
  */
@@ -59,10 +59,10 @@ const inputSchema = {
 const outputSchema = {
   type: 'object',
   properties: {
-    result: { type: 'boolean' },
-    matchedConditions: { type: 'array' },
-    data: { type: 'any' },
+    result: { type: 'boolean', description: 'Whether the condition evaluated to true' },
+    matchedConditions: { type: 'array', description: 'List of conditions that matched' },
     _activeBranch: { type: 'string', description: 'Active output branch (true or false)' }
+    // Data is passed through on the active branch (true or false)
   }
 };
 
@@ -115,11 +115,24 @@ function evaluateCondition(data, condition) {
   let fieldValue;
   const field = condition.field;
 
-  // If field is a pure number (from expression evaluation), use it directly
-  if (/^\d+$/.test(field) || /^\d+\.\d+$/.test(field)) {
-    fieldValue = parseFloat(field);
+  // If field is already a non-string value (boolean, number, etc. from expression evaluation),
+  // use it directly instead of trying to resolve it as a path
+  if (typeof field === 'boolean') {
+    fieldValue = field;
+  } else if (typeof field === 'number') {
+    fieldValue = field;
+  } else if (typeof field === 'string') {
+    // If field is a pure number string, parse it
+    if (/^\d+$/.test(field) || /^\d+\.\d+$/.test(field)) {
+      fieldValue = parseFloat(field);
+    } else {
+      fieldValue = getFieldValue(data, field);
+    }
+  } else if (field === null || field === undefined) {
+    fieldValue = field;
   } else {
-    fieldValue = getFieldValue(data, field);
+    // For objects/arrays, use directly
+    fieldValue = field;
   }
 
   const compareValue = condition.value;
@@ -220,8 +233,8 @@ async function execute(config, input, context) {
     return {
       result: true,
       matchedConditions: [],
-      data: data,
-      _activeBranch: 'true'
+      _activeBranch: 'true',
+      true: data  // Pass data only on the active branch
     };
   }
 
@@ -254,14 +267,12 @@ async function execute(config, input, context) {
 
   smartbotic.log.info(`IF condition evaluated to ${finalResult} (${matchedConditions.length}/${conditions.length} conditions matched) -> branch: ${activeBranch}`);
 
-  // Return data on the active branch key for conditional execution
+  // Return data only on the active branch - no duplication
   return {
     result: finalResult,
     matchedConditions: matchedConditions,
-    data: data,
     _activeBranch: activeBranch,
-    // Put the data under the active branch name for easy downstream access
-    [activeBranch]: data
+    [activeBranch]: data  // Data is only on the active branch
   };
 }
 

+ 28 - 17
nodes/core/loop.js

@@ -2,7 +2,7 @@
  * @node loop
  * @name Loop
  * @category flow-control
- * @version 1.0.0
+ * @version 1.3.0
  * @description Iterate over arrays - executes connected nodes for each item
  * @icon repeat
  */
@@ -65,6 +65,7 @@ const inputSchema = {
 const outputSchema = {
   type: 'object',
   properties: {
+    // Internal fields for engine (not shown to user)
     _isLoop: { type: 'boolean', description: 'Loop marker for engine' },
     _items: { type: 'array', description: 'Array of items to iterate' },
     _outputField: { type: 'string', description: 'Name for collected results' },
@@ -72,10 +73,10 @@ const outputSchema = {
     _indexVariable: { type: 'string', description: 'Variable name for current index' },
     _continueOnError: { type: 'boolean', description: 'Continue on error flag' },
     _activeBranch: { type: 'string', description: 'Active branch (loop or done)' },
-    currentItem: { type: 'any', description: 'Current item being processed' },
-    currentIndex: { type: 'number', description: 'Current iteration index' },
+    // User-accessible fields (item/index names are dynamic based on config)
     totalItems: { type: 'number', description: 'Total number of items' },
-    data: { type: 'any', description: 'Original input data' }
+    isFirst: { type: 'boolean', description: 'True if this is the first iteration' },
+    isLast: { type: 'boolean', description: 'True if this is the last iteration' }
   }
 };
 
@@ -111,7 +112,20 @@ async function execute(config, input, context) {
   const maxIterations = config.maxIterations || 0;
 
   // Get the array to iterate
-  let items = getFieldValue(data, inputField);
+  // inputField can be:
+  // 1. An already-evaluated array (from {{expression}} that was resolved by the engine)
+  // 2. A string path like "data.items" (raw path without expression syntax)
+  let items;
+  if (Array.isArray(inputField)) {
+    // Expression was already evaluated to an array by the engine
+    items = inputField;
+  } else if (typeof inputField === 'string') {
+    // Raw path - resolve it from the data
+    items = getFieldValue(data, inputField);
+  } else {
+    // Unexpected type - try to use as-is
+    items = inputField;
+  }
 
   // Handle case where input itself is the array
   if (!Array.isArray(items) && Array.isArray(data)) {
@@ -120,7 +134,8 @@ async function execute(config, input, context) {
 
   // Validate input
   if (!Array.isArray(items)) {
-    smartbotic.log.warn(`Loop node: Input at "${inputField}" is not an array, converting to single-item array`);
+    const fieldDesc = typeof inputField === 'string' ? inputField : 'evaluated expression';
+    smartbotic.log.warn(`Loop node: Input at "${fieldDesc}" is not an array, converting to single-item array`);
     items = items !== undefined ? [items] : [];
   }
 
@@ -152,7 +167,10 @@ async function execute(config, input, context) {
   smartbotic.log.info(`Loop node: Starting iteration over ${totalItems} items`);
 
   // Return loop control data for the engine
+  // Internal fields (prefixed with _) are used by the engine
+  // User-accessible fields use the configured variable names
   return {
+    // Engine control fields
     _isLoop: true,
     _items: items,
     _outputField: outputField,
@@ -161,20 +179,13 @@ async function execute(config, input, context) {
     _continueOnError: continueOnError,
     _activeBranch: 'loop',
 
-    // For first iteration (engine will update these)
-    currentItem: items[0],
-    currentIndex: 0,
-    totalItems: totalItems,
-    data: data,
-
-    // Loop output includes current item info
+    // Loop output uses user-configured variable names
     loop: {
-      [itemVariable]: items[0],
-      [indexVariable]: 0,
+      [itemVariable]: items[0],      // Current item (using configured name)
+      [indexVariable]: 0,            // Current index (using configured name)
       totalItems: totalItems,
       isFirst: true,
-      isLast: totalItems === 1,
-      data: items[0]  // Current item, not original input
+      isLast: totalItems === 1
     }
   };
 }

+ 165 - 0
nodes/core/schedule-trigger.js

@@ -0,0 +1,165 @@
+/**
+ * @node schedule-trigger
+ * @name Schedule Trigger
+ * @category core
+ * @version 1.0.0
+ * @description Triggers workflows on a schedule using interval or cron expressions. Use for periodic tasks like backups, reports, or data syncing.
+ * @trigger
+ * @scheduled
+ * @icon clock
+ */
+
+const configSchema = {
+  type: 'object',
+  properties: {
+    mode: {
+      type: 'string',
+      title: 'Schedule Mode',
+      description: 'How to define the schedule',
+      enum: ['interval', 'cron'],
+      default: 'interval'
+    },
+    pollInterval: {
+      type: 'integer',
+      title: 'Interval (minutes)',
+      description: 'How often to trigger the workflow (for interval mode)',
+      default: 60,
+      minimum: 1,
+      maximum: 43200
+    },
+    cronExpression: {
+      type: 'string',
+      title: 'Cron Expression',
+      description: 'Cron expression for scheduling (for cron mode). Format: minute hour day month weekday',
+      default: '0 * * * *'
+    },
+    timezone: {
+      type: 'string',
+      title: 'Timezone',
+      description: 'Timezone for cron expression (e.g., UTC, Europe/Budapest)',
+      default: 'UTC'
+    },
+    triggerName: {
+      type: 'string',
+      title: 'Trigger Name',
+      description: 'Optional name to identify this trigger in logs',
+      default: ''
+    }
+  },
+  required: ['mode', 'pollInterval']
+}
+
+const inputSchema = {
+  type: 'object',
+  properties: {}
+}
+
+const outputSchema = {
+  type: 'object',
+  properties: {
+    timestamp: {
+      type: 'string',
+      description: 'ISO timestamp when the trigger fired'
+    },
+    timestampMs: {
+      type: 'integer',
+      description: 'Unix timestamp in milliseconds'
+    },
+    date: {
+      type: 'string',
+      description: 'Date in YYYY-MM-DD format'
+    },
+    time: {
+      type: 'string',
+      description: 'Time in HH:MM:SS format'
+    },
+    dayOfWeek: {
+      type: 'integer',
+      description: 'Day of week (0=Sunday, 6=Saturday)'
+    },
+    dayOfMonth: {
+      type: 'integer',
+      description: 'Day of month (1-31)'
+    },
+    month: {
+      type: 'integer',
+      description: 'Month (1-12)'
+    },
+    year: {
+      type: 'integer',
+      description: 'Year'
+    },
+    hour: {
+      type: 'integer',
+      description: 'Hour (0-23)'
+    },
+    minute: {
+      type: 'integer',
+      description: 'Minute (0-59)'
+    },
+    triggerName: {
+      type: 'string',
+      description: 'Configured trigger name'
+    },
+    executionCount: {
+      type: 'integer',
+      description: 'Number of times this trigger has fired (since workflow activation)'
+    }
+  }
+}
+
+const outputs = [
+  { name: 'main', displayName: 'Trigger', type: 'object', color: '#4ade80' }
+]
+
+/**
+ * Schedule trigger execution
+ * @param {Object} config - Node configuration
+ * @param {Object} input - Input data (unused for triggers)
+ * @param {Object} context - Execution context
+ * @returns {Promise<Object>} Schedule information
+ */
+async function execute(config, input, context) {
+  const now = new Date()
+
+  // If timezone is provided, try to use it (note: full timezone support would need a library)
+  // For now, we'll output UTC and the configured timezone for reference
+
+  const output = {
+    timestamp: now.toISOString(),
+    timestampMs: now.getTime(),
+    date: now.toISOString().split('T')[0],
+    time: now.toISOString().split('T')[1].split('.')[0],
+    dayOfWeek: now.getUTCDay(),
+    dayOfMonth: now.getUTCDate(),
+    month: now.getUTCMonth() + 1,
+    year: now.getUTCFullYear(),
+    hour: now.getUTCHours(),
+    minute: now.getUTCMinutes(),
+    triggerName: config.triggerName || 'Schedule Trigger',
+    mode: config.mode,
+    timezone: config.timezone || 'UTC',
+    // Execution count would need to be tracked externally
+    executionCount: context.executionCount || 1
+  }
+
+  // Add mode-specific info
+  if (config.mode === 'interval') {
+    output.intervalMinutes = config.pollInterval
+    output.nextRunEstimate = new Date(now.getTime() + (config.pollInterval * 60 * 1000)).toISOString()
+  } else if (config.mode === 'cron') {
+    output.cronExpression = config.cronExpression
+  }
+
+  return {
+    main: output
+  }
+}
+
+module.exports = {
+  configSchema,
+  inputSchema,
+  outputSchema,
+  outputs,
+  execute
+}

+ 28 - 17
nodes/imap/imap-fetch.js

@@ -2,7 +2,7 @@
  * @node imap-fetch
  * @name IMAP Fetch Email
  * @category email
- * @version 1.0.1
+ * @version 1.2.0
  * @description Fetch the full content of an email from an IMAP server.
  * @icon download
  */
@@ -30,11 +30,11 @@ const configSchema = {
             title: 'Email UID',
             description: 'The unique identifier of the email to fetch (can be provided from input)'
         },
-        includeRaw: {
+        peek: {
             type: 'boolean',
-            title: 'Include Raw',
-            description: 'Include the raw email content in the output',
-            default: false
+            title: 'Peek (Don\'t Mark as Read)',
+            description: 'Fetch without marking the email as read',
+            default: true
         }
     },
     required: ['credentialId']
@@ -57,13 +57,15 @@ const inputSchema = {
 const outputSchema = {
     type: 'object',
     properties: {
-        uid: { type: 'string' },
+        uid: { type: 'string', description: 'Unique email identifier' },
         subject: { type: 'string' },
         from: { type: 'string' },
         to: { type: 'string' },
         date: { type: 'string' },
-        body: { type: 'string' },
-        raw: { type: 'string' }
+        body: { type: 'string', description: 'Email body text' },
+        raw: { type: 'string', description: 'Raw email content (for attachment extraction)' },
+        hasAttachments: { type: 'boolean', description: 'Whether email has attachments' },
+        mailbox: { type: 'string' }
     }
 };
 
@@ -71,6 +73,18 @@ const outputs = [
     { name: 'main', displayName: 'Email Content', type: 'object', color: '#8b5cf6' }
 ];
 
+/**
+ * Check if email has attachments by looking for Content-Disposition: attachment
+ * or multipart content types
+ */
+function detectAttachments(rawEmail) {
+    if (!rawEmail) return false;
+    const lower = rawEmail.toLowerCase();
+    return lower.includes('content-disposition: attachment') ||
+           lower.includes('content-disposition:attachment') ||
+           (lower.includes('multipart/mixed') && lower.includes('boundary='));
+}
+
 module.exports = {
     configSchema,
     inputSchema,
@@ -81,7 +95,7 @@ module.exports = {
         const credentialId = config.credentialId;
         const mailbox = input.mailbox || config.mailbox || 'INBOX';
         const uid = input.uid || config.uid;
-        const includeRaw = config.includeRaw || false;
+        const peek = config.peek !== false;  // Default to true (don't mark as read)
 
         if (!credentialId) {
             throw new Error('IMAP credential is required');
@@ -95,27 +109,24 @@ module.exports = {
         const fetchResult = smartbotic.imap.fetch({
             credentialId,
             mailbox,
-            uid
+            uid,
+            peek
         });
 
         if (!fetchResult.success) {
             throw new Error(`IMAP fetch failed: ${fetchResult.error}`);
         }
 
-        const result = {
+        return {
             uid: fetchResult.uid,
             subject: fetchResult.subject,
             from: fetchResult.from,
             to: fetchResult.to,
             date: fetchResult.date,
             body: fetchResult.body,
+            raw: fetchResult.raw,
+            hasAttachments: detectAttachments(fetchResult.raw),
             mailbox
         };
-
-        if (includeRaw) {
-            result.raw = fetchResult.raw;
-        }
-
-        return result;
     }
 };

+ 46 - 7
nodes/imap/imap-search.js

@@ -2,7 +2,7 @@
  * @node imap-search
  * @name IMAP Search
  * @category email
- * @version 1.0.1
+ * @version 1.2.0
  * @description Search and list emails from an IMAP server with various filters.
  * @icon search
  */
@@ -62,8 +62,14 @@ const configSchema = {
         },
         fetchContent: {
             type: 'boolean',
-            title: 'Fetch Content',
-            description: 'Fetch full email content for each result',
+            title: 'Fetch Full Content',
+            description: 'Fetch full email content including body and raw data (required for attachment extraction)',
+            default: false
+        },
+        markAsRead: {
+            type: 'boolean',
+            title: 'Mark as Read',
+            description: 'Mark emails as read after fetching (only applies when Fetch Full Content is enabled)',
             default: false
         }
     },
@@ -98,8 +104,23 @@ const outputSchema = {
         },
         emails: {
             type: 'array',
-            description: 'Array of email objects (if fetchContent is enabled)'
-        }
+            description: 'Array of email objects (if fetchContent is enabled)',
+            items: {
+                type: 'object',
+                properties: {
+                    uid: { type: 'string', description: 'Unique email identifier' },
+                    subject: { type: 'string' },
+                    from: { type: 'string' },
+                    to: { type: 'string' },
+                    date: { type: 'string' },
+                    body: { type: 'string', description: 'Email body' },
+                    raw: { type: 'string', description: 'Raw email content' },
+                    hasAttachments: { type: 'boolean', description: 'Whether email has attachments' }
+                }
+            }
+        },
+        mailbox: { type: 'string' },
+        criteria: { type: 'string' }
     }
 };
 
@@ -107,6 +128,18 @@ const outputs = [
     { name: 'main', displayName: 'Search Results', type: 'object', color: '#3b82f6' }
 ];
 
+/**
+ * Check if email has attachments by looking for Content-Disposition: attachment
+ * or multipart content types
+ */
+function detectAttachments(rawEmail) {
+    if (!rawEmail) return false;
+    const lower = rawEmail.toLowerCase();
+    return lower.includes('content-disposition: attachment') ||
+           lower.includes('content-disposition:attachment') ||
+           (lower.includes('multipart/mixed') && lower.includes('boundary='));
+}
+
 module.exports = {
     configSchema,
     inputSchema,
@@ -118,6 +151,7 @@ module.exports = {
         const mailbox = input.mailbox || config.mailbox || 'INBOX';
         const limit = config.limit || 50;
         const fetchContent = config.fetchContent || false;
+        const markAsRead = config.markAsRead || false;
 
         if (!credentialId) {
             throw new Error('IMAP credential is required');
@@ -166,10 +200,13 @@ module.exports = {
             result.emails = [];
 
             for (const uid of searchResult.uids) {
+                // Use peek: true to avoid marking as read during fetch
+                // Only mark as read explicitly if markAsRead is enabled
                 const fetchResult = smartbotic.imap.fetch({
                     credentialId,
                     mailbox,
-                    uid
+                    uid,
+                    peek: !markAsRead  // peek=true prevents marking as read
                 });
 
                 if (fetchResult.success) {
@@ -179,7 +216,9 @@ module.exports = {
                         from: fetchResult.from,
                         to: fetchResult.to,
                         date: fetchResult.date,
-                        body: fetchResult.body
+                        body: fetchResult.body,
+                        raw: fetchResult.raw,
+                        hasAttachments: detectAttachments(fetchResult.raw)
                     });
                 }
             }

+ 93 - 19
nodes/imap/imap-trigger.js

@@ -2,9 +2,10 @@
  * @node imap-trigger
  * @name IMAP Email Trigger
  * @category email
- * @version 1.0.1
+ * @version 1.3.0
  * @description Triggers when new emails are received. Polls the IMAP server for new messages.
  * @trigger
+ * @scheduled
  * @icon mail
  */
 
@@ -20,6 +21,14 @@ const configSchema = {
                 filter: { type: 'imap' }
             }
         },
+        pollInterval: {
+            type: 'integer',
+            title: 'Poll Interval (minutes)',
+            description: 'How often to check for new emails when workflow is active. Set to 0 for manual-only execution.',
+            default: 5,
+            minimum: 0,
+            maximum: 1440
+        },
         mailbox: {
             type: 'string',
             title: 'Mailbox',
@@ -55,17 +64,66 @@ const configSchema = {
             default: 10,
             minimum: 1,
             maximum: 100
+        },
+        fetchContent: {
+            type: 'boolean',
+            title: 'Fetch Full Content',
+            description: 'Fetch full email content including body and raw data (required for attachment extraction)',
+            default: true
         }
     },
     required: ['credentialId']
 };
 
+const outputSchema = {
+    type: 'object',
+    properties: {
+        emails: {
+            type: 'array',
+            items: {
+                type: 'object',
+                properties: {
+                    uid: { type: 'string', description: 'Unique email identifier' },
+                    subject: { type: 'string' },
+                    from: { type: 'string' },
+                    to: { type: 'string' },
+                    date: { type: 'string' },
+                    body: { type: 'string', description: 'Email body (if fetchContent enabled)' },
+                    raw: { type: 'string', description: 'Raw email content (if fetchContent enabled)' },
+                    hasAttachments: { type: 'boolean', description: 'Whether email has attachments' }
+                }
+            }
+        },
+        uids: {
+            type: 'array',
+            items: { type: 'string' },
+            description: 'Array of message UIDs'
+        },
+        count: { type: 'integer' },
+        mailbox: { type: 'string' },
+        timestamp: { type: 'number' }
+    }
+};
+
 const outputs = [
     { name: 'main', displayName: 'Email Received', type: 'object', color: '#22c55e' }
 ];
 
+/**
+ * Check if email has attachments by looking for Content-Disposition: attachment
+ * or multipart content types
+ */
+function detectAttachments(rawEmail) {
+    if (!rawEmail) return false;
+    const lower = rawEmail.toLowerCase();
+    return lower.includes('content-disposition: attachment') ||
+           lower.includes('content-disposition:attachment') ||
+           (lower.includes('multipart/mixed') && lower.includes('boundary='));
+}
+
 module.exports = {
     configSchema,
+    outputSchema,
     outputs,
 
     async execute(config, input, context) {
@@ -76,7 +134,8 @@ module.exports = {
             filterFrom,
             filterSubject,
             markAsRead = true,
-            limit = 10
+            limit = 10,
+            fetchContent = true
         } = config;
 
         if (!credentialId) {
@@ -107,26 +166,40 @@ module.exports = {
         }
 
         const emails = [];
+        const uids = searchResult.uids || [];
 
-        // Fetch each email
-        for (const uid of searchResult.uids) {
-            const fetchResult = smartbotic.imap.fetch({
-                credentialId,
-                mailbox,
-                uid
-            });
-
-            if (fetchResult.success) {
-                emails.push({
-                    uid: fetchResult.uid,
-                    subject: fetchResult.subject,
-                    from: fetchResult.from,
-                    to: fetchResult.to,
-                    date: fetchResult.date,
-                    body: fetchResult.body
+        // Fetch each email if fetchContent is enabled
+        if (fetchContent) {
+            for (const uid of uids) {
+                // Use peek: true to avoid marking as read during fetch
+                // Only mark as read explicitly if markAsRead is enabled
+                const fetchResult = smartbotic.imap.fetch({
+                    credentialId,
+                    mailbox,
+                    uid,
+                    peek: !markAsRead  // peek=true prevents marking as read
                 });
 
-                // Mark as read if configured
+                if (fetchResult.success) {
+                    const email = {
+                        uid: fetchResult.uid,
+                        subject: fetchResult.subject,
+                        from: fetchResult.from,
+                        to: fetchResult.to,
+                        date: fetchResult.date,
+                        body: fetchResult.body,
+                        raw: fetchResult.raw,
+                        hasAttachments: detectAttachments(fetchResult.raw)
+                    };
+                    emails.push(email);
+                }
+            }
+        } else {
+            // Just return UIDs without fetching content
+            for (const uid of uids) {
+                emails.push({ uid });
+
+                // Mark as read if configured (when not fetching content)
                 if (markAsRead) {
                     smartbotic.imap.modify({
                         credentialId,
@@ -140,6 +213,7 @@ module.exports = {
 
         return {
             emails,
+            uids,
             count: emails.length,
             mailbox,
             timestamp: Date.now()

+ 126 - 22
src/runner/imap/imap_client.cpp

@@ -133,51 +133,155 @@ common::Result<SearchResult> ImapClient::search(
 
 common::Result<EmailContent> ImapClient::fetch(
     const std::string& mailbox,
-    const std::string& uid
+    const std::string& uid,
+    bool peek
 ) {
-    LOG_INFO("IMAP fetch - mailbox: '{}', uid: '{}'", mailbox, uid);
+    LOG_INFO("IMAP fetch - mailbox: '{}', uid: '{}', peek: {}", mailbox, uid, peek);
 
-    // Select mailbox and fetch the message
+    // Use curl's built-in IMAP URL format which properly retrieves message content
+    // Format: imaps://host/MAILBOX;UID=uid/;SECTION=TEXT or just ;UID=uid for full message
+    // Note: Using the UID URL method, curl will mark the message as read.
+    // To avoid this when peek=true, we'll mark it as unread after fetching.
     std::string url_path = escapeMailbox(mailbox) + ";UID=" + uid;
 
-    auto response = performCommand(url_path);
+    auto response = performCommand(url_path, "");
+
+    // If peek mode, mark the message as unread to undo the \Seen flag
+    if (peek && response.error.empty()) {
+        std::string store_path = escapeMailbox(mailbox);
+        std::string store_cmd = "STORE " + uid + " -FLAGS (\\Seen)";
+        auto store_response = performCommand(store_path, store_cmd);
+        if (!store_response.error.empty()) {
+            LOG_WARN("Failed to remove \\Seen flag after peek fetch: {}", store_response.error);
+        }
+    }
 
     if (!response.error.empty()) {
         return common::Error(common::ErrorCode::Internal,
             "IMAP fetch failed: " + response.error);
     }
 
+    LOG_INFO("IMAP fetch response length: {} bytes", response.data.size());
+
     EmailContent content;
     content.uid = uid;
-    content.raw = response.data;
 
-    // Parse headers from the raw email
-    std::istringstream stream(response.data);
+    // Extract email content from IMAP FETCH response
+    // Response format: "* UID FETCH (BODY[] {size}\r\n<email content>\r\n)\r\n"
+    std::string email_data = response.data;
+
+    // Check if this is an IMAP FETCH response wrapper
+    // Look for "* " at start and "{size}" literal indicator
+    size_t fetch_pos = email_data.find("* ");
+    if (fetch_pos != std::string::npos) {
+        // Find the literal size indicator {number}
+        size_t brace_start = email_data.find('{', fetch_pos);
+        size_t brace_end = email_data.find('}', brace_start);
+
+        if (brace_start != std::string::npos && brace_end != std::string::npos) {
+            // Extract the size
+            std::string size_str = email_data.substr(brace_start + 1, brace_end - brace_start - 1);
+            size_t email_size = 0;
+            try {
+                email_size = std::stoull(size_str);
+            } catch (...) {
+                LOG_WARN("Failed to parse email size from IMAP response");
+            }
+
+            // Find the start of actual email content (after }\r\n or }\n)
+            size_t content_start = brace_end + 1;
+            if (content_start < email_data.size() && email_data[content_start] == '\r') {
+                content_start++;
+            }
+            if (content_start < email_data.size() && email_data[content_start] == '\n') {
+                content_start++;
+            }
+
+            // Extract the email content
+            if (email_size > 0 && content_start + email_size <= email_data.size()) {
+                email_data = email_data.substr(content_start, email_size);
+                LOG_DEBUG("Extracted email content: {} bytes from IMAP response", email_data.size());
+            } else if (content_start < email_data.size()) {
+                // Fallback: take everything after the header, trim trailing IMAP response
+                email_data = email_data.substr(content_start);
+                // Remove trailing ")\r\n" or similar IMAP response terminators
+                while (!email_data.empty() &&
+                       (email_data.back() == ')' || email_data.back() == '\r' ||
+                        email_data.back() == '\n' || email_data.back() == ' ')) {
+                    email_data.pop_back();
+                }
+                LOG_DEBUG("Extracted email content (fallback): {} bytes", email_data.size());
+            }
+        }
+    }
+
+    content.raw = email_data;
+
+    // Parse headers from the email content
+    std::istringstream stream(email_data);
     std::string line;
     bool in_headers = true;
     std::ostringstream body_stream;
+    std::string current_header_name;
+    std::string current_header_value;
+
+    auto process_header = [&content](const std::string& name, const std::string& value) {
+        if (name == "subject") {
+            content.subject = value;
+        } else if (name == "from") {
+            content.from = value;
+        } else if (name == "to") {
+            content.to = value;
+        } else if (name == "date") {
+            content.date = value;
+        }
+    };
 
     while (std::getline(stream, line)) {
+        // Remove trailing \r if present
+        if (!line.empty() && line.back() == '\r') {
+            line.pop_back();
+        }
+
         if (in_headers) {
-            if (line.empty() || line == "\r") {
+            if (line.empty()) {
+                // End of headers, process last header if any
+                if (!current_header_name.empty()) {
+                    process_header(current_header_name, current_header_value);
+                }
                 in_headers = false;
                 continue;
             }
 
-            // Parse common headers
-            if (line.find("Subject:") == 0) {
-                content.subject = line.substr(8);
-                // Trim whitespace
-                content.subject.erase(0, content.subject.find_first_not_of(" \t"));
-            } else if (line.find("From:") == 0) {
-                content.from = line.substr(5);
-                content.from.erase(0, content.from.find_first_not_of(" \t"));
-            } else if (line.find("To:") == 0) {
-                content.to = line.substr(3);
-                content.to.erase(0, content.to.find_first_not_of(" \t"));
-            } else if (line.find("Date:") == 0) {
-                content.date = line.substr(5);
-                content.date.erase(0, content.date.find_first_not_of(" \t"));
+            // Check for header continuation (line starts with whitespace)
+            if (!line.empty() && (line[0] == ' ' || line[0] == '\t')) {
+                // Continuation of previous header
+                if (!current_header_name.empty()) {
+                    current_header_value += " " + line.substr(line.find_first_not_of(" \t"));
+                }
+                continue;
+            }
+
+            // New header - process previous one first
+            if (!current_header_name.empty()) {
+                process_header(current_header_name, current_header_value);
+            }
+
+            // Parse new header
+            size_t colon_pos = line.find(':');
+            if (colon_pos != std::string::npos) {
+                current_header_name = line.substr(0, colon_pos);
+                // Convert to lowercase for comparison
+                std::transform(current_header_name.begin(), current_header_name.end(),
+                             current_header_name.begin(), ::tolower);
+                current_header_value = line.substr(colon_pos + 1);
+                // Trim leading whitespace
+                size_t start = current_header_value.find_first_not_of(" \t");
+                if (start != std::string::npos) {
+                    current_header_value = current_header_value.substr(start);
+                } else {
+                    current_header_value.clear();
+                }
             }
         } else {
             body_stream << line << "\n";

+ 3 - 1
src/runner/imap/imap_client.hpp

@@ -74,11 +74,13 @@ public:
      * Fetch full email content
      * @param mailbox The mailbox containing the email
      * @param uid The email UID
+     * @param peek If true, don't mark the email as read (uses BODY.PEEK[])
      * @return Email content
      */
     common::Result<EmailContent> fetch(
         const std::string& mailbox,
-        const std::string& uid
+        const std::string& uid,
+        bool peek = false
     );
 
     /**

+ 1 - 0
src/runner/workflow_engine.hpp

@@ -69,6 +69,7 @@ struct NodeExecutionResult {
     int64_t started_at = 0;
     int64_t finished_at = 0;
     int retry_count = 0;
+    bool from_cache = false;  // True if output was from cached previous execution
 };
 
 // Execution status

+ 4 - 0
src/webserver/api/execution_controller.cpp

@@ -63,6 +63,10 @@ void ExecutionController::listExecutions(const httplib::Request& req, httplib::R
     options.page_size = page_size;
     options.sorts.push_back({"startedAt", false});
 
+    // Only fetch summary fields to avoid large responses
+    // Excludes nodeExecutions and workflowSnapshot which can be very large
+    options.fields = {"workflowId", "workflowName", "status", "triggerType", "startedAt", "finishedAt", "error", "runnerId"};
+
     auto result = storage_.query("executions", options);
     if (result.failed()) {
         sendError(res, result.error().message(), 500);

+ 2 - 0
src/webserver/api/node_controller.cpp

@@ -77,6 +77,7 @@ void NodeController::listNodes(const httplib::Request& req, httplib::Response& r
         n["description"] = node.description;
         n["icon"] = node.icon;
         n["isTrigger"] = node.is_trigger;
+        n["isScheduled"] = node.is_scheduled;
         n["ownerId"] = node.owner_id;
         n["createdAt"] = node.created_at;
         n["updatedAt"] = node.updated_at;
@@ -141,6 +142,7 @@ void NodeController::getNode(const httplib::Request& req, httplib::Response& res
     n["description"] = node.description;
     n["icon"] = node.icon;
     n["isTrigger"] = node.is_trigger;
+    n["isScheduled"] = node.is_scheduled;
     n["ownerId"] = node.owner_id;
     n["createdAt"] = node.created_at;
     n["updatedAt"] = node.updated_at;

+ 99 - 2
src/webserver/api/workflow_controller.cpp

@@ -12,12 +12,16 @@ WorkflowController::WorkflowController(storage::StorageClient& storage,
                                        auth::AuthMiddleware& middleware,
                                        runners::RunnerRegistry& registry,
                                        runners::LoadBalancer& load_balancer,
-                                       WebSocketServer& ws_server)
+                                       WebSocketServer& ws_server,
+                                       WorkflowScheduler& scheduler,
+                                       nodes::NodeStore& node_store)
     : storage_(storage)
     , middleware_(middleware)
     , registry_(registry)
     , load_balancer_(load_balancer)
-    , ws_server_(ws_server) {}
+    , ws_server_(ws_server)
+    , scheduler_(scheduler)
+    , node_store_(node_store) {}
 
 void WorkflowController::registerRoutes(httplib::Server& server) {
     server.Get("/api/v1/workflows", [this](const httplib::Request& req, httplib::Response& res) {
@@ -67,6 +71,16 @@ void WorkflowController::registerRoutes(httplib::Server& server) {
             deactivateWorkflow(req, res, ctx);
         });
     });
+
+    // Scheduler status endpoint
+    server.Get("/api/v1/scheduler/status", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            nlohmann::json response;
+            response["scheduledWorkflows"] = scheduler_.getScheduledWorkflows();
+            response["count"] = scheduler_.getRegisteredCount();
+            sendJson(res, response);
+        });
+    });
 }
 
 void WorkflowController::listWorkflows(const httplib::Request& req, httplib::Response& res,
@@ -188,6 +202,11 @@ void WorkflowController::updateWorkflow(const httplib::Request& req, httplib::Re
         // Get updated workflow
         auto workflow = storage_.get("workflows", id);
         if (workflow.ok()) {
+            // If workflow is active, update scheduler registration
+            if (workflow.value().value("active", false)) {
+                updateScheduledTriggers(id, true);
+            }
+
             ws_server_.broadcast("workflows.updated", workflow.value());
             sendJson(res, workflow.value());
         } else {
@@ -202,6 +221,9 @@ void WorkflowController::deleteWorkflow(const httplib::Request& req, httplib::Re
                                          const auth::AuthContext& ctx) {
     std::string id = req.matches[1];
 
+    // Unregister from scheduler before deleting
+    scheduler_.unregisterWorkflow(id);
+
     auto result = storage_.remove("workflows", id);
     if (result.failed()) {
         sendError(res, "Workflow not found", 404);
@@ -291,6 +313,9 @@ void WorkflowController::activateWorkflow(const httplib::Request& req, httplib::
         return;
     }
 
+    // Register with scheduler if has scheduled triggers
+    updateScheduledTriggers(id, true);
+
     ws_server_.broadcast("workflows.activated", {{"id", id}});
     sendJson(res, {{"success", true}, {"active", true}});
 }
@@ -305,10 +330,82 @@ void WorkflowController::deactivateWorkflow(const httplib::Request& req, httplib
         return;
     }
 
+    // Unregister from scheduler
+    scheduler_.unregisterWorkflow(id);
+
     ws_server_.broadcast("workflows.deactivated", {{"id", id}});
     sendJson(res, {{"success", true}, {"active", false}});
 }
 
+void WorkflowController::updateScheduledTriggers(const std::string& workflow_id, bool activate) {
+    if (!activate) {
+        scheduler_.unregisterWorkflow(workflow_id);
+        return;
+    }
+
+    // Get workflow
+    auto workflow_result = storage_.get("workflows", workflow_id);
+    if (workflow_result.failed()) {
+        return;
+    }
+
+    auto& workflow = workflow_result.value();
+    std::string workflow_name = workflow.value("name", "Unknown");
+
+    // Find scheduled trigger nodes
+    auto nodes = workflow.value("nodes", nlohmann::json::array());
+    for (const auto& node : nodes) {
+        std::string node_type = node.value("type", "");
+        std::string node_id = node.value("id", "");
+
+        // Get node definition to check if it's a scheduled trigger
+        auto node_result = node_store_.get(node_type);
+        if (node_result.failed() || !node_result.value().is_trigger) {
+            continue;
+        }
+
+        auto& node_def = node_result.value();
+
+        // Check if node has scheduling capability (is_scheduled flag or pollInterval config)
+        bool is_scheduled = node_def.is_scheduled;
+        if (!is_scheduled) {
+            continue;
+        }
+
+        // Get interval from node config
+        auto config = node.value("config", nlohmann::json::object());
+        int interval = config.value("pollInterval", 0);
+
+        if (interval > 0) {
+            scheduler_.registerWorkflow(
+                workflow_id,
+                workflow_name,
+                node_id,
+                node_type,
+                interval
+            );
+            LOG_INFO("Registered workflow '{}' for scheduled execution every {} minutes",
+                     workflow_name, interval);
+        }
+    }
+}
+
+int WorkflowController::getScheduledInterval(const nlohmann::json& workflow) {
+    auto nodes = workflow.value("nodes", nlohmann::json::array());
+    for (const auto& node : nodes) {
+        std::string node_type = node.value("type", "");
+
+        auto node_result = node_store_.get(node_type);
+        if (node_result.failed() || !node_result.value().is_trigger || !node_result.value().is_scheduled) {
+            continue;
+        }
+
+        auto config = node.value("config", nlohmann::json::object());
+        return config.value("pollInterval", 0);
+    }
+    return 0;
+}
+
 void WorkflowController::sendJson(httplib::Response& res, const nlohmann::json& data, int status) {
     res.status = status;
     res.set_content(data.dump(), "application/json");

+ 11 - 1
src/webserver/api/workflow_controller.hpp

@@ -7,6 +7,8 @@
 #include "../runners/runner_registry.hpp"
 #include "../runners/load_balancer.hpp"
 #include "../websocket_server.hpp"
+#include "../scheduler/workflow_scheduler.hpp"
+#include "../nodes/node_store.hpp"
 #include "proto/runner.grpc.pb.h"
 
 namespace smartbotic::webserver::api {
@@ -17,7 +19,9 @@ public:
                       auth::AuthMiddleware& middleware,
                       runners::RunnerRegistry& registry,
                       runners::LoadBalancer& load_balancer,
-                      WebSocketServer& ws_server);
+                      WebSocketServer& ws_server,
+                      WorkflowScheduler& scheduler,
+                      nodes::NodeStore& node_store);
 
     void registerRoutes(httplib::Server& server);
 
@@ -50,6 +54,12 @@ private:
     runners::RunnerRegistry& registry_;
     runners::LoadBalancer& load_balancer_;
     WebSocketServer& ws_server_;
+    WorkflowScheduler& scheduler_;
+    nodes::NodeStore& node_store_;
+
+    // Helper to check for scheduled triggers and register/unregister with scheduler
+    void updateScheduledTriggers(const std::string& workflow_id, bool activate);
+    int getScheduledInterval(const nlohmann::json& workflow);
 };
 
 } // namespace smartbotic::webserver::api

+ 6 - 0
src/webserver/nodes/node_store.cpp

@@ -96,6 +96,7 @@ nlohmann::json StoredNode::toJson() const {
     j["icon"] = icon;
     j["code"] = code;
     j["isTrigger"] = is_trigger;
+    j["isScheduled"] = is_scheduled;
     j["configSchema"] = config_schema;
     j["inputSchema"] = input_schema;
     j["outputSchema"] = output_schema;
@@ -125,6 +126,7 @@ StoredNode StoredNode::fromJson(const nlohmann::json& j) {
     node.icon = j.value("icon", "");
     node.code = j.value("code", "");
     node.is_trigger = j.value("isTrigger", false);
+    node.is_scheduled = j.value("isScheduled", false);
     node.owner_id = j.value("ownerId", "");
     node.created_at = j.value("_createdAt", int64_t{0});
     node.updated_at = j.value("_updatedAt", int64_t{0});
@@ -166,6 +168,7 @@ StoredNode StoredNode::parseFromCode(const std::string& code, const std::string&
     std::regex version_regex(R"(@version\s+(\S+))");
     std::regex description_regex(R"(@description\s+(.+))");
     std::regex trigger_regex(R"(@trigger)");
+    std::regex scheduled_regex(R"(@scheduled)");
     std::regex icon_regex(R"(@icon\s+(\S+))");
 
     std::smatch match;
@@ -191,6 +194,9 @@ StoredNode StoredNode::parseFromCode(const std::string& code, const std::string&
     if (std::regex_search(code, trigger_regex)) {
         node.is_trigger = true;
     }
+    if (std::regex_search(code, scheduled_regex)) {
+        node.is_scheduled = true;
+    }
 
     // Parse configSchema
     std::regex config_start_regex(R"(const\s+configSchema\s*=\s*)");

+ 1 - 0
src/webserver/nodes/node_store.hpp

@@ -36,6 +36,7 @@ struct StoredNode {
     std::vector<NodeIO> inputs;
     std::vector<NodeIO> outputs;
     bool is_trigger = false;
+    bool is_scheduled = false;  // For triggers that support scheduled/periodic execution
     std::string owner_id;
     int64_t created_at = 0;
     int64_t updated_at = 0;

+ 197 - 0
src/webserver/scheduler/workflow_scheduler.cpp

@@ -0,0 +1,197 @@
+#include "workflow_scheduler.hpp"
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::webserver {
+
+WorkflowScheduler::WorkflowScheduler() = default;
+
+WorkflowScheduler::~WorkflowScheduler() {
+    stop();
+}
+
+void WorkflowScheduler::setExecuteCallback(ExecuteCallback callback) {
+    std::lock_guard<std::mutex> lock(mutex_);
+    execute_callback_ = std::move(callback);
+}
+
+void WorkflowScheduler::start() {
+    if (running_) {
+        return;
+    }
+
+    running_ = true;
+    scheduler_thread_ = std::thread(&WorkflowScheduler::schedulerLoop, this);
+    spdlog::info("Workflow scheduler started");
+}
+
+void WorkflowScheduler::stop() {
+    if (!running_) {
+        return;
+    }
+
+    running_ = false;
+    if (scheduler_thread_.joinable()) {
+        scheduler_thread_.join();
+    }
+    spdlog::info("Workflow scheduler stopped");
+}
+
+void WorkflowScheduler::registerWorkflow(
+    const std::string& workflow_id,
+    const std::string& workflow_name,
+    const std::string& trigger_node_id,
+    const std::string& trigger_type,
+    int interval_minutes
+) {
+    if (interval_minutes <= 0) {
+        spdlog::debug("Workflow {} has interval 0, not scheduling", workflow_id);
+        return;
+    }
+
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    auto now = std::chrono::steady_clock::now();
+
+    ScheduledWorkflow entry;
+    entry.workflow_id = workflow_id;
+    entry.workflow_name = workflow_name;
+    entry.trigger_node_id = trigger_node_id;
+    entry.trigger_type = trigger_type;
+    entry.interval_minutes = interval_minutes;
+    entry.last_run = now;  // Consider it just ran to avoid immediate execution
+    entry.next_run = now + std::chrono::minutes(interval_minutes);
+    entry.running = false;
+
+    workflows_[workflow_id] = entry;
+
+    spdlog::info("Scheduled workflow '{}' ({}) with {} trigger, interval: {} minutes",
+                 workflow_name, workflow_id, trigger_type, interval_minutes);
+}
+
+void WorkflowScheduler::unregisterWorkflow(const std::string& workflow_id) {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    auto it = workflows_.find(workflow_id);
+    if (it != workflows_.end()) {
+        spdlog::info("Unscheduled workflow '{}' ({})",
+                     it->second.workflow_name, workflow_id);
+        workflows_.erase(it);
+    }
+}
+
+bool WorkflowScheduler::isRegistered(const std::string& workflow_id) const {
+    std::lock_guard<std::mutex> lock(mutex_);
+    return workflows_.find(workflow_id) != workflows_.end();
+}
+
+nlohmann::json WorkflowScheduler::getScheduledWorkflows() const {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    nlohmann::json result = nlohmann::json::array();
+    auto now = std::chrono::steady_clock::now();
+
+    for (const auto& [id, entry] : workflows_) {
+        auto seconds_until_next = std::chrono::duration_cast<std::chrono::seconds>(
+            entry.next_run - now
+        ).count();
+
+        result.push_back({
+            {"workflowId", entry.workflow_id},
+            {"workflowName", entry.workflow_name},
+            {"triggerNodeId", entry.trigger_node_id},
+            {"triggerType", entry.trigger_type},
+            {"intervalMinutes", entry.interval_minutes},
+            {"running", entry.running},
+            {"secondsUntilNextRun", seconds_until_next > 0 ? seconds_until_next : 0}
+        });
+    }
+
+    return result;
+}
+
+size_t WorkflowScheduler::getRegisteredCount() const {
+    std::lock_guard<std::mutex> lock(mutex_);
+    return workflows_.size();
+}
+
+void WorkflowScheduler::triggerNow(const std::string& workflow_id) {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    auto it = workflows_.find(workflow_id);
+    if (it != workflows_.end()) {
+        // Set next_run to now to trigger on next check
+        it->second.next_run = std::chrono::steady_clock::now();
+        spdlog::info("Triggered immediate execution for workflow '{}'", workflow_id);
+    }
+}
+
+void WorkflowScheduler::schedulerLoop() {
+    spdlog::info("Scheduler loop started, check interval: {} seconds", CHECK_INTERVAL_SECONDS);
+
+    while (running_) {
+        checkAndExecute();
+
+        // Sleep with periodic checks to allow quick shutdown
+        for (int i = 0; i < CHECK_INTERVAL_SECONDS && running_; ++i) {
+            std::this_thread::sleep_for(std::chrono::seconds(1));
+        }
+    }
+
+    spdlog::info("Scheduler loop ended");
+}
+
+void WorkflowScheduler::checkAndExecute() {
+    auto now = std::chrono::steady_clock::now();
+
+    // Collect workflows that need to run
+    std::vector<ScheduledWorkflow> to_execute;
+
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+
+        for (auto& [id, entry] : workflows_) {
+            // Skip if already running
+            if (entry.running) {
+                continue;
+            }
+
+            // Check if it's time to run
+            if (now >= entry.next_run) {
+                entry.running = true;
+                to_execute.push_back(entry);
+            }
+        }
+    }
+
+    // Execute outside the lock
+    for (const auto& entry : to_execute) {
+        spdlog::info("Scheduler executing workflow '{}' ({}) - {} trigger",
+                     entry.workflow_name, entry.workflow_id, entry.trigger_type);
+
+        if (execute_callback_) {
+            try {
+                execute_callback_(entry.workflow_id, entry.trigger_node_id, entry.trigger_type);
+            } catch (const std::exception& e) {
+                spdlog::error("Scheduler execution failed for workflow {}: {}",
+                              entry.workflow_id, e.what());
+            }
+        }
+
+        // Update last_run and next_run, clear running flag
+        {
+            std::lock_guard<std::mutex> lock(mutex_);
+            auto it = workflows_.find(entry.workflow_id);
+            if (it != workflows_.end()) {
+                it->second.running = false;
+                it->second.last_run = now;
+                it->second.next_run = now + std::chrono::minutes(it->second.interval_minutes);
+
+                auto next_in_minutes = it->second.interval_minutes;
+                spdlog::debug("Workflow '{}' next run in {} minutes",
+                              entry.workflow_name, next_in_minutes);
+            }
+        }
+    }
+}
+
+} // namespace smartbotic::webserver

+ 118 - 0
src/webserver/scheduler/workflow_scheduler.hpp

@@ -0,0 +1,118 @@
+#pragma once
+
+#include <string>
+#include <map>
+#include <mutex>
+#include <thread>
+#include <atomic>
+#include <chrono>
+#include <functional>
+#include <nlohmann/json.hpp>
+
+namespace smartbotic::webserver {
+
+/**
+ * Scheduled workflow entry
+ */
+struct ScheduledWorkflow {
+    std::string workflow_id;
+    std::string workflow_name;
+    std::string trigger_node_id;
+    std::string trigger_type;
+    int interval_minutes;
+    std::chrono::steady_clock::time_point last_run;
+    std::chrono::steady_clock::time_point next_run;
+    bool running = false;
+};
+
+/**
+ * Workflow Scheduler Service
+ *
+ * Manages periodic execution of workflows with scheduled triggers (e.g., IMAP).
+ * When a workflow with a scheduled trigger is activated, it registers here
+ * and gets executed at the configured interval.
+ */
+class WorkflowScheduler {
+public:
+    using ExecuteCallback = std::function<void(const std::string& workflow_id,
+                                                const std::string& trigger_node_id,
+                                                const std::string& trigger_type)>;
+
+    WorkflowScheduler();
+    ~WorkflowScheduler();
+
+    // Disable copy
+    WorkflowScheduler(const WorkflowScheduler&) = delete;
+    WorkflowScheduler& operator=(const WorkflowScheduler&) = delete;
+
+    /**
+     * Set the callback function that executes workflows
+     */
+    void setExecuteCallback(ExecuteCallback callback);
+
+    /**
+     * Start the scheduler thread
+     */
+    void start();
+
+    /**
+     * Stop the scheduler thread
+     */
+    void stop();
+
+    /**
+     * Register a workflow for scheduled execution
+     * @param workflow_id Workflow ID
+     * @param workflow_name Workflow name (for logging)
+     * @param trigger_node_id The trigger node ID to use
+     * @param trigger_type Type of trigger (e.g., "imap-trigger")
+     * @param interval_minutes Execution interval in minutes
+     */
+    void registerWorkflow(const std::string& workflow_id,
+                          const std::string& workflow_name,
+                          const std::string& trigger_node_id,
+                          const std::string& trigger_type,
+                          int interval_minutes);
+
+    /**
+     * Unregister a workflow from scheduled execution
+     */
+    void unregisterWorkflow(const std::string& workflow_id);
+
+    /**
+     * Check if a workflow is registered
+     */
+    bool isRegistered(const std::string& workflow_id) const;
+
+    /**
+     * Get all scheduled workflows (for status/monitoring)
+     */
+    nlohmann::json getScheduledWorkflows() const;
+
+    /**
+     * Get count of registered workflows
+     */
+    size_t getRegisteredCount() const;
+
+    /**
+     * Trigger immediate execution of a scheduled workflow
+     */
+    void triggerNow(const std::string& workflow_id);
+
+private:
+    void schedulerLoop();
+    void checkAndExecute();
+
+    mutable std::mutex mutex_;
+    std::map<std::string, ScheduledWorkflow> workflows_;
+
+    std::thread scheduler_thread_;
+    std::atomic<bool> running_{false};
+
+    ExecuteCallback execute_callback_;
+
+    // Check interval for the scheduler loop (how often to check if workflows need to run)
+    static constexpr int CHECK_INTERVAL_SECONDS = 10;
+};
+
+} // namespace smartbotic::webserver

+ 67 - 1
src/webserver/webserver_service.cpp

@@ -12,7 +12,10 @@
 #include "grpc/node_sync_service.hpp"
 #include "grpc/credential_service.hpp"
 #include "credentials/credential_store.hpp"
+#include "scheduler/workflow_scheduler.hpp"
 #include "logging/logger.hpp"
+#include <grpcpp/grpcpp.h>
+#include "proto/runner.grpc.pb.h"
 
 namespace smartbotic::webserver {
 
@@ -58,6 +61,14 @@ WebServerService::WebServerService(const WebServerServiceConfig& config)
     credential_store_ = std::make_unique<credentials::CredentialStore>(*storage_, cred_config);
     credential_store_->initialize();
 
+    // Initialize workflow scheduler
+    scheduler_ = std::make_unique<WorkflowScheduler>();
+    scheduler_->setExecuteCallback([this](const std::string& workflow_id,
+                                          const std::string& trigger_node_id,
+                                          const std::string& trigger_type) {
+        executeScheduledWorkflow(workflow_id, trigger_node_id, trigger_type);
+    });
+
     // Initialize HTTP server
     HttpServerConfig http_config;
     http_config.port = config_.http_port;
@@ -132,6 +143,9 @@ void WebServerService::start() {
     // Start runner registry cleanup
     runner_registry_->start();
 
+    // Start workflow scheduler
+    scheduler_->start();
+
     // Start NodeSync gRPC server for runners
     node_sync_server_->start();
 
@@ -154,6 +168,7 @@ void WebServerService::stop() {
     ws_server_->stop();
     credential_server_->stop();
     node_sync_server_->stop();
+    scheduler_->stop();
     runner_registry_->stop();
 
     LOG_INFO("WebServer service stopped");
@@ -180,7 +195,8 @@ void WebServerService::setupRoutes() {
     user_ctrl_->registerRoutes(server);
 
     workflow_ctrl_ = std::make_unique<api::WorkflowController>(
-        *storage_, *auth_middleware_, *runner_registry_, *load_balancer_, *ws_server_);
+        *storage_, *auth_middleware_, *runner_registry_, *load_balancer_, *ws_server_,
+        *scheduler_, *node_store_);
     workflow_ctrl_->registerRoutes(server);
 
     execution_ctrl_ = std::make_unique<api::ExecutionController>(*storage_, *auth_middleware_, *ws_server_);
@@ -206,4 +222,54 @@ void WebServerService::setupRoutes() {
     LOG_INFO("API routes registered");
 }
 
+void WebServerService::executeScheduledWorkflow(const std::string& workflow_id,
+                                                 const std::string& trigger_node_id,
+                                                 const std::string& trigger_type) {
+    // Select a runner
+    auto runner = load_balancer_->selectRunner();
+    if (!runner) {
+        LOG_ERROR("No runners available for scheduled workflow {}", workflow_id);
+        return;
+    }
+
+    // Create gRPC channel and stub
+    auto channel = ::grpc::CreateChannel(runner->address, ::grpc::InsecureChannelCredentials());
+    auto stub = proto::RunnerService::NewStub(channel);
+
+    // Prepare request
+    proto::ExecuteWorkflowRequest request;
+    request.set_workflow_id(workflow_id);
+    request.set_trigger_type(trigger_type);
+
+    nlohmann::json trigger_data;
+    trigger_data["triggerNodeId"] = trigger_node_id;
+    trigger_data["scheduledExecution"] = true;
+    request.set_trigger_data(trigger_data.dump());
+    request.set_wait_for_completion(false);
+
+    // Execute
+    proto::ExecuteWorkflowResponse response;
+    ::grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(30));
+
+    auto status = stub->ExecuteWorkflow(&context, request, &response);
+
+    if (!status.ok()) {
+        LOG_ERROR("Failed to execute scheduled workflow {}: {}", workflow_id, status.error_message());
+        return;
+    }
+
+    LOG_INFO("Scheduled workflow {} execution started: {} on runner {}",
+             workflow_id, response.execution_id(), runner->id);
+
+    // Broadcast execution started
+    ws_server_->broadcast("executions." + response.execution_id() + ".started", {
+        {"executionId", response.execution_id()},
+        {"workflowId", workflow_id},
+        {"runnerId", runner->id},
+        {"triggeredBy", trigger_type},
+        {"scheduled", true}
+    });
+}
+
 } // namespace smartbotic::webserver

+ 8 - 0
src/webserver/webserver_service.hpp

@@ -10,6 +10,7 @@
 #include "runners/load_balancer.hpp"
 #include "storage/storage_client.hpp"
 #include "config/config_loader.hpp"
+#include "scheduler/workflow_scheduler.hpp"
 
 namespace smartbotic::webserver::nodes {
     class NodeStore;
@@ -69,9 +70,13 @@ public:
     auth::AuthStore& authStore() { return *auth_store_; }
     runners::RunnerRegistry& runnerRegistry() { return *runner_registry_; }
     WebSocketServer& wsServer() { return *ws_server_; }
+    WorkflowScheduler& scheduler() { return *scheduler_; }
 
 private:
     void setupRoutes();
+    void executeScheduledWorkflow(const std::string& workflow_id,
+                                  const std::string& trigger_node_id,
+                                  const std::string& trigger_type);
 
     WebServerServiceConfig config_;
 
@@ -91,6 +96,9 @@ private:
     std::unique_ptr<grpc::NodeSyncServer> node_sync_server_;
     std::unique_ptr<grpc::CredentialServer> credential_server_;
 
+    // Scheduler
+    std::unique_ptr<WorkflowScheduler> scheduler_;
+
     // Controllers (must outlive httplib::Server callbacks)
     std::unique_ptr<api::AuthController> auth_ctrl_;
     std::unique_ptr<api::UserController> user_ctrl_;

+ 27 - 0
webui/src/api/client.ts

@@ -79,6 +79,8 @@ export class WebSocketClient {
   private maxReconnectAttempts: number = 5
   private baseReconnectDelay: number = 1000
   private maxReconnectDelay: number = 30000
+  private reconnectCallbacks: Set<() => void> = new Set()
+  private wasConnected: boolean = false
 
   connect() {
     // Don't try to connect if not logged in
@@ -111,12 +113,27 @@ export class WebSocketClient {
 
     this.ws.onopen = () => {
       console.log('WebSocket connected')
+      const isReconnect = this.wasConnected
       this.reconnectAttempts = 0 // Reset on successful connection
+      this.wasConnected = true
+
       // Authenticate
       const token = useAuthStore.getState().accessToken
       if (token) {
         this.send({ type: 'auth', token: `Bearer ${token}` })
       }
+
+      // Notify listeners about reconnection so they can reset state
+      if (isReconnect) {
+        console.log('WebSocket reconnected - notifying listeners to reset state')
+        this.reconnectCallbacks.forEach(callback => {
+          try {
+            callback()
+          } catch (e) {
+            console.error('Error in reconnect callback:', e)
+          }
+        })
+      }
     }
 
     this.ws.onmessage = (event) => {
@@ -213,6 +230,16 @@ export class WebSocketClient {
   off(channel: string, handler: (data: any) => void) {
     this.messageHandlers.get(channel)?.delete(handler)
   }
+
+  // Register a callback to be called when WebSocket reconnects
+  // Useful for components to reset their execution state
+  onReconnect(callback: () => void) {
+    this.reconnectCallbacks.add(callback)
+  }
+
+  offReconnect(callback: () => void) {
+    this.reconnectCallbacks.delete(callback)
+  }
 }
 
 export const wsClient = new WebSocketClient()

+ 16 - 12
webui/src/api/workflows.ts

@@ -105,10 +105,17 @@ export const workflowsApi = {
   },
 
   // Execute workflow up to a specific node (for testing single nodes)
-  executeToNode: async (id: string, targetNodeId: string, triggerData?: any) => {
+  // cachedOutputs: Map of nodeId -> { output, configHash } for upstream nodes with valid cached data
+  executeToNode: async (
+    id: string,
+    targetNodeId: string,
+    triggerData?: any,
+    cachedOutputs?: Record<string, { output: any; configHash: string }>
+  ) => {
     const response = await api.post(`/workflows/${id}/execute`, {
       ...triggerData,
       _targetNodeId: targetNodeId,
+      _cachedOutputs: cachedOutputs,
     })
     return response.data
   },
@@ -294,13 +301,16 @@ function transformExecutionDetail(data: any): ExecutionDetail {
 }
 
 export const executionsApi = {
-  list: async (workflowId?: string, status?: string, page = 1, pageSize = 20) => {
+  list: async (workflowId?: string, status?: string, page = 1, pageSize = 20): Promise<{ executions: ExecutionListItem[]; total: number; page: number; pageSize: number; hasMore: boolean }> => {
     const response = await api.get('/executions', {
       params: { workflowId, status, page, pageSize },
     })
     return {
-      ...response.data,
       executions: (response.data.executions || []).map(transformExecutionListItem),
+      total: response.data.total || 0,
+      page: response.data.page || page,
+      pageSize: response.data.pageSize || pageSize,
+      hasMore: response.data.hasMore || false,
     }
   },
 
@@ -315,15 +325,9 @@ export const executionsApi = {
     return transformExecutionDetail(response.data)
   },
 
-  // List executions for a specific workflow
-  listForWorkflow: async (workflowId: string, limit = 20): Promise<{ executions: ExecutionListItem[]; total: number }> => {
-    const response = await api.get('/executions', {
-      params: { workflowId, pageSize: limit },
-    })
-    return {
-      ...response.data,
-      executions: (response.data.executions || []).map(transformExecutionListItem),
-    }
+  // List executions for a specific workflow (convenience wrapper)
+  listForWorkflow: async (workflowId: string, page = 1, pageSize = 20): Promise<{ executions: ExecutionListItem[]; total: number; hasMore: boolean }> => {
+    return executionsApi.list(workflowId, undefined, page, pageSize)
   },
 
   cancel: async (id: string) => {

+ 34 - 7
webui/src/components/workflow/ExecutionListPanel.tsx

@@ -10,6 +10,8 @@ import {
   Eye,
   Pin,
   Loader2,
+  ChevronLeft,
+  ChevronRight,
 } from 'lucide-react'
 import { executionsApi, type ExecutionListItem, type ExecutionDetail } from '../../api/workflows'
 
@@ -21,6 +23,8 @@ interface ExecutionListPanelProps {
   onPinExecution: (execution: ExecutionDetail) => void
 }
 
+const PAGE_SIZE = 20
+
 function formatDuration(startedAt: number, finishedAt: number): string {
   const durationMs = finishedAt - startedAt
   if (durationMs < 1000) return `${durationMs}ms`
@@ -70,10 +74,11 @@ export function ExecutionListPanel({
   onPinExecution,
 }: ExecutionListPanelProps) {
   const [loadingId, setLoadingId] = useState<string | null>(null)
+  const [page, setPage] = useState(1)
 
   const { data, isLoading, error } = useQuery({
-    queryKey: ['executions', workflowId],
-    queryFn: () => executionsApi.listForWorkflow(workflowId, 50),
+    queryKey: ['executions', workflowId, page],
+    queryFn: () => executionsApi.list(workflowId, undefined, page, PAGE_SIZE),
     enabled: isOpen && !!workflowId,
     refetchInterval: 5000,
   })
@@ -97,10 +102,14 @@ export function ExecutionListPanel({
     }
   }
 
+  const total = data?.total || 0
+  const totalPages = Math.ceil(total / PAGE_SIZE)
+  const hasMore = data?.hasMore || false
+
   if (!isOpen) return null
 
   return (
-    <div className="w-80 border-l border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 flex flex-col h-full">
+    <div className="w-80 border-r border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 flex flex-col h-full shrink-0">
       {/* Header */}
       <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 shrink-0">
         <div className="flex items-center gap-2">
@@ -188,10 +197,28 @@ export function ExecutionListPanel({
         )}
       </div>
 
-      {/* Footer */}
-      {data?.total && data.total > (data.executions?.length || 0) && (
-        <div className="px-4 py-3 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 text-center text-xs text-gray-500 dark:text-gray-400">
-          Showing {data.executions.length} of {data.total} executions
+      {/* Footer with pagination */}
+      {total > 0 && (
+        <div className="px-4 py-3 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 shrink-0">
+          <div className="flex items-center justify-between">
+            <button
+              onClick={() => setPage(p => Math.max(1, p - 1))}
+              disabled={page === 1}
+              className="p-1.5 hover:bg-gray-200 dark:hover:bg-slate-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              <ChevronLeft className="w-4 h-4 text-gray-600 dark:text-gray-400" />
+            </button>
+            <span className="text-xs text-gray-500 dark:text-gray-400">
+              Page {page} of {totalPages || 1} ({total} total)
+            </span>
+            <button
+              onClick={() => setPage(p => p + 1)}
+              disabled={!hasMore && page >= totalPages}
+              className="p-1.5 hover:bg-gray-200 dark:hover:bg-slate-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              <ChevronRight className="w-4 h-4 text-gray-600 dark:text-gray-400" />
+            </button>
+          </div>
         </div>
       )}
     </div>

+ 10 - 3
webui/src/components/workflow/ExecutionResultsPanel.tsx

@@ -256,9 +256,16 @@ export function ExecutionResultsPanel({
                   <span className="w-4 h-4 text-gray-400 dark:text-gray-500 flex items-center justify-center text-xs">⊘</span>
                 )}
 
-                <span className="flex-1 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
-                  {node.data.label}
-                </span>
+                <div className="flex-1 min-w-0">
+                  <span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate block">
+                    {node.data.config?._customLabel || node.data.label}
+                  </span>
+                  {node.data.config?._customLabel && (
+                    <span className="text-xs text-gray-500 dark:text-gray-400 truncate block">
+                      {node.data.label} ({node.data.type})
+                    </span>
+                  )}
+                </div>
 
                 {/* Iteration count badge for loop nodes */}
                 {isLoopNode && loopIterCount > 0 && (

+ 23 - 2
webui/src/components/workflow/NodeConfigModal.tsx

@@ -83,6 +83,26 @@ export function NodeConfigModal({
             </div>
           </div>
           <div className="flex-1 overflow-auto p-4 space-y-4">
+            {/* Custom node name */}
+            <div>
+              <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                Node Name
+              </label>
+              <input
+                type="text"
+                value={editingConfig._customLabel || ''}
+                onChange={(e) => onConfigChange({ ...editingConfig, _customLabel: e.target.value })}
+                placeholder={selectedNodeData.name}
+                className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm
+                  bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100
+                  placeholder-gray-400 dark:placeholder-gray-500
+                  focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
+              />
+              <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
+                Custom name for this node. Leave empty to use default: {selectedNodeData.name}
+              </p>
+            </div>
+
             <div>
               <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Node Type</label>
               <div className="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-slate-900 px-3 py-2 rounded">
@@ -287,13 +307,14 @@ export function NodeConfigModal({
                         />
                         <span className="text-sm text-gray-600 dark:text-gray-400">{prop.description}</span>
                       </label>
-                    ) : prop.type === 'number' ? (
+                    ) : prop.type === 'number' || prop.type === 'integer' ? (
                       <input
                         type="number"
                         value={editingConfig[key] ?? prop.default ?? ''}
                         onChange={(e) =>
-                          onConfigChange({ ...editingConfig, [key]: Number(e.target.value) })
+                          onConfigChange({ ...editingConfig, [key]: prop.type === 'integer' ? parseInt(e.target.value, 10) : Number(e.target.value) })
                         }
+                        step={prop.type === 'integer' ? 1 : undefined}
                         placeholder={prop.description}
                         className="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
                       />

+ 9 - 3
webui/src/components/workflow/WorkflowNode.tsx

@@ -117,12 +117,18 @@ export function WorkflowNode({ data, selected }: NodeProps) {
           {data.icon === 'globe' && <span className="text-sm">🌐</span>}
           {data.icon === 'git-branch' && <span className="text-sm">⑂</span>}
           {data.icon === 'repeat' && <span className="text-sm">🔁</span>}
-          {!data.icon && <span className="text-xs font-medium">{data.label?.charAt(0) || '?'}</span>}
+          {!data.icon && <span className="text-xs font-medium">{(data.config?._customLabel || data.label)?.charAt(0) || '?'}</span>}
         </div>
 
         <div className="flex-1 min-w-0">
-          <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{data.label}</div>
-          <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{data.type}</div>
+          {/* Show custom label if set, otherwise default label */}
+          <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
+            {data.config?._customLabel || data.label}
+          </div>
+          {/* Always show node type (and default label if custom is set) */}
+          <div className="text-xs text-gray-500 dark:text-gray-400 truncate">
+            {data.config?._customLabel ? `${data.label} (${data.type})` : data.type}
+          </div>
         </div>
 
         {/* Status indicator */}

+ 100 - 62
webui/src/pages/ExecutionsPage.tsx

@@ -1,7 +1,8 @@
+import { useState } from 'react'
 import { useQuery } from '@tanstack/react-query'
 import { executionsApi } from '../api/workflows'
 import { formatDistanceToNow } from 'date-fns'
-import { CheckCircle, XCircle, Clock, Ban, RefreshCw } from 'lucide-react'
+import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react'
 import clsx from 'clsx'
 
 // Helper to safely format timestamps that might be invalid or too large
@@ -16,14 +17,21 @@ function safeFormatDistanceToNow(timestamp: number): string {
   }
 }
 
+const PAGE_SIZE = 20
+
 export default function ExecutionsPage() {
+  const [page, setPage] = useState(1)
+
   const { data, isLoading, refetch } = useQuery({
-    queryKey: ['executions'],
-    queryFn: () => executionsApi.list(),
+    queryKey: ['executions', page],
+    queryFn: () => executionsApi.list(undefined, undefined, page, PAGE_SIZE),
     refetchInterval: 5000,
   })
 
   const executions = data?.executions || []
+  const total = data?.total || 0
+  const totalPages = Math.ceil(total / PAGE_SIZE)
+  const hasMore = data?.hasMore || false
 
   const getStatusIcon = (status: string) => {
     switch (status) {
@@ -77,66 +85,96 @@ export default function ExecutionsPage() {
           <p className="text-gray-500 dark:text-gray-400">No executions yet</p>
         </div>
       ) : (
-        <div className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 overflow-hidden">
-          <table className="w-full">
-            <thead className="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-slate-600">
-              <tr>
-                <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
-                  Status
-                </th>
-                <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
-                  Workflow
-                </th>
-                <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
-                  Trigger
-                </th>
-                <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
-                  Started
-                </th>
-                <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
-                  Duration
-                </th>
-              </tr>
-            </thead>
-            <tbody className="divide-y divide-gray-200 dark:divide-slate-700">
-              {executions.map((execution: any) => (
-                <tr key={execution.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/50">
-                  <td className="px-4 py-3">
-                    <div className="flex items-center gap-2">
-                      {getStatusIcon(execution.status)}
-                      <span
-                        className={clsx(
-                          'px-2 py-0.5 rounded-full text-xs font-medium',
-                          getStatusBadgeClass(execution.status)
-                        )}
-                      >
-                        {execution.status}
-                      </span>
-                    </div>
-                  </td>
-                  <td className="px-4 py-3">
-                    <span className="font-medium text-gray-900 dark:text-gray-100">{execution.workflowName || execution.workflowId}</span>
-                  </td>
-                  <td className="px-4 py-3 text-gray-600 dark:text-gray-400">
-                    {execution.triggerType}
-                  </td>
-                  <td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-sm">
-                    {execution.startedAt
-                      ? safeFormatDistanceToNow(execution.startedAt)
-                      : '-'}
-                  </td>
-                  <td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-sm">
-                    {execution.finishedAt && execution.startedAt
-                      ? `${((execution.finishedAt - execution.startedAt) / 1000).toFixed(2)}s`
-                      : execution.status === 'running'
-                      ? 'Running...'
-                      : '-'}
-                  </td>
+        <>
+          <div className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 overflow-hidden">
+            <table className="w-full">
+              <thead className="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-slate-600">
+                <tr>
+                  <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
+                    Status
+                  </th>
+                  <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
+                    Workflow
+                  </th>
+                  <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
+                    Trigger
+                  </th>
+                  <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
+                    Started
+                  </th>
+                  <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
+                    Duration
+                  </th>
                 </tr>
-              ))}
-            </tbody>
-          </table>
-        </div>
+              </thead>
+              <tbody className="divide-y divide-gray-200 dark:divide-slate-700">
+                {executions.map((execution: any) => (
+                  <tr key={execution.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/50">
+                    <td className="px-4 py-3">
+                      <div className="flex items-center gap-2">
+                        {getStatusIcon(execution.status)}
+                        <span
+                          className={clsx(
+                            'px-2 py-0.5 rounded-full text-xs font-medium',
+                            getStatusBadgeClass(execution.status)
+                          )}
+                        >
+                          {execution.status}
+                        </span>
+                      </div>
+                    </td>
+                    <td className="px-4 py-3">
+                      <span className="font-medium text-gray-900 dark:text-gray-100">{execution.workflowName || execution.workflowId}</span>
+                    </td>
+                    <td className="px-4 py-3 text-gray-600 dark:text-gray-400">
+                      {execution.triggerType}
+                    </td>
+                    <td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-sm">
+                      {execution.startedAt
+                        ? safeFormatDistanceToNow(execution.startedAt)
+                        : '-'}
+                    </td>
+                    <td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-sm">
+                      {execution.finishedAt && execution.startedAt
+                        ? `${((execution.finishedAt - execution.startedAt) / 1000).toFixed(2)}s`
+                        : execution.status === 'running'
+                        ? 'Running...'
+                        : '-'}
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </div>
+
+          {/* Pagination */}
+          <div className="flex items-center justify-between mt-4 px-2">
+            <div className="text-sm text-gray-600 dark:text-gray-400">
+              Showing {((page - 1) * PAGE_SIZE) + 1} - {Math.min(page * PAGE_SIZE, total)} of {total} executions
+            </div>
+            <div className="flex items-center gap-2">
+              <button
+                onClick={() => setPage(p => Math.max(1, p - 1))}
+                disabled={page === 1}
+                className="flex items-center gap-1 px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed"
+              >
+                <ChevronLeft className="w-4 h-4" />
+                Previous
+              </button>
+              <span className="text-sm text-gray-600 dark:text-gray-400">
+                Page {page} of {totalPages || 1}
+              </span>
+              <button
+                onClick={() => setPage(p => p + 1)}
+                disabled={!hasMore && page >= totalPages}
+                className="flex items-center gap-1 px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed"
+              >
+                Next
+                <ChevronRight className="w-4 h-4" />
+              </button>
+            </div>
+          </div>
+        </>
       )}
     </div>
   )

+ 123 - 46
webui/src/pages/WorkflowEditorPage.tsx

@@ -246,7 +246,7 @@ function WorkflowEditorInner() {
     const { currentNodes, currentConnections } = getCurrentWorkflowData()
     pinExecution(execution, currentNodes, currentConnections)
     setViewingExecution(true) // Must be called AFTER pinExecution (which resets it to false)
-    setShowExecutionList(false)
+    // Keep execution list open for continuous navigation
     showToast('info', 'Viewing execution results')
   }, [getCurrentWorkflowData, pinExecution, setViewingExecution, showToast])
 
@@ -254,7 +254,7 @@ function WorkflowEditorInner() {
   const handlePinExecution = useCallback((execution: ExecutionDetail) => {
     const { currentNodes, currentConnections } = getCurrentWorkflowData()
     pinExecution(execution, currentNodes, currentConnections)
-    setShowExecutionList(false)
+    // Keep execution list open for continuous navigation
     showToast('success', 'Execution data pinned')
   }, [getCurrentWorkflowData, pinExecution, showToast])
 
@@ -364,10 +364,51 @@ function WorkflowEditorInner() {
     executeWorkflow(triggerNodeId)
   }, [executeWorkflow])
 
+  // Helper to create a simple hash of node config for cache validation
+  const hashConfig = useCallback((config: any): string => {
+    return JSON.stringify(config || {})
+  }, [])
+
   // Execute workflow up to a specific node (run single node)
   const executeNode = useCallback((targetNodeId: string) => {
     if (!id) return
 
+    // Build cached outputs for upstream nodes that have unchanged configs
+    // This allows the backend to skip re-executing nodes that haven't changed
+    const cachedOutputs: Record<string, { output: any; configHash: string }> = {}
+
+    // Collect all upstream node IDs (nodes that feed into the target)
+    const collectUpstreamNodes = (nodeId: string, visited: Set<string> = new Set()): string[] => {
+      if (visited.has(nodeId)) return []
+      visited.add(nodeId)
+
+      const upstreamIds: string[] = []
+      for (const edge of edges) {
+        if (edge.target === nodeId) {
+          upstreamIds.push(edge.source)
+          upstreamIds.push(...collectUpstreamNodes(edge.source, visited))
+        }
+      }
+      return upstreamIds
+    }
+
+    const upstreamNodeIds = collectUpstreamNodes(targetNodeId)
+
+    // For each upstream node, if we have cached output, include it
+    for (const nodeId of upstreamNodeIds) {
+      const cachedOutput = lastExecutionResults[nodeId]
+      if (cachedOutput !== undefined) {
+        // Find the node to get its current config
+        const node = nodes.find(n => n.id === nodeId)
+        if (node) {
+          cachedOutputs[nodeId] = {
+            output: cachedOutput,
+            configHash: hashConfig(node.data?.config),
+          }
+        }
+      }
+    }
+
     // Set pending marker BEFORE HTTP request to capture early WebSocket events
     pendingExecIdRef.current = 'pending'
 
@@ -382,7 +423,8 @@ function WorkflowEditorInner() {
     setShowExecutionPanel(true)
     pendingEventsRef.current = []
 
-    workflowsApi.executeToNode(id, targetNodeId).then((result) => {
+    // Pass cached outputs to the backend
+    workflowsApi.executeToNode(id, targetNodeId, undefined, cachedOutputs).then((result) => {
       pendingExecIdRef.current = result.executionId
       setExecutionState((prev) => ({
         ...prev,
@@ -399,7 +441,7 @@ function WorkflowEditorInner() {
       setExecutingNodeId(null)
       showToast('error', `Execution failed: ${error.message}`)
     })
-  }, [id, showToast])
+  }, [id, showToast, nodes, edges, lastExecutionResults, hashConfig])
 
   // WebSocket subscription for execution updates - subscribe on mount, filter in handler
   useEffect(() => {
@@ -659,9 +701,35 @@ function WorkflowEditorInner() {
     wsClient.subscribe(['executions.*'])
     wsClient.on('executions.*', handleExecutionEvent)
 
+    // Handle WebSocket reconnection - reset execution state to prevent stuck workflows
+    const handleReconnect = () => {
+      console.log('WebSocket reconnected - resetting execution state')
+      // If there was a pending execution, it's now stale - reset it
+      if (pendingExecIdRef.current) {
+        pendingExecIdRef.current = null
+        pendingEventsRef.current = []
+        setExecutionState((prev) => {
+          // Only reset if execution was still running
+          if (prev.status === 'running') {
+            showToast('info', 'Connection restored - execution state reset. Please re-run if needed.')
+            return {
+              executionId: null,
+              status: 'idle',
+              nodeStates: {},
+            }
+          }
+          return prev
+        })
+        setExecutingNodeId(null)
+      }
+    }
+
+    wsClient.onReconnect(handleReconnect)
+
     return () => {
       wsClient.unsubscribe(['executions.*'])
       wsClient.off('executions.*', handleExecutionEvent)
+      wsClient.offReconnect(handleReconnect)
     }
   }, [showToast]) // Subscribe on mount, unsubscribe on unmount
 
@@ -1562,7 +1630,8 @@ function WorkflowEditorInner() {
     traverse(nodeId)
 
     for (const { node: sourceNode } of orderedNodes) {
-      const nodeName = sourceNode.data.label || sourceNode.data.type
+      // Use custom label if set, otherwise fall back to default label or type
+      const nodeName = sourceNode.data.config?._customLabel || sourceNode.data.label || sourceNode.data.type
       // Check if this source node is a direct upstream (immediate predecessor) of the target node
       const isDirectUpstream = directUpstreamIds.has(sourceNode.id)
       // Get the targetInput for direct upstream nodes
@@ -1624,52 +1693,42 @@ function WorkflowEditorInner() {
         if (nodeDef) {
           if (sourceNode.data.type === 'loop') {
             const loopConfig = sourceNode.data.config || {}
-            const inputField = loopConfig.inputField || 'body'
+            const itemVariable = loopConfig.itemVariableName || 'item'
+            const indexVariable = loopConfig.indexVariableName || 'index'
 
-            const incomingEdgesForLoop = edges.filter((e) => e.target === sourceNode.id)
+            // Get sample item from the loop node's own execution result
+            // The loop node stores the first item in its 'loop' output during execution
             let sampleItem: any = null
-
-            for (const edge of incomingEdgesForLoop) {
-              const upstreamResult = lastExecutionResults[edge.source]
-              if (!upstreamResult) continue
-
-              const pathParts = inputField.split('.')
-              let arrayData = upstreamResult
-              for (const part of pathParts) {
-                if (arrayData && typeof arrayData === 'object' && part in arrayData) {
-                  arrayData = arrayData[part]
-                } else {
-                  arrayData = null
-                  break
-                }
-              }
-              if (Array.isArray(arrayData) && arrayData.length > 0) {
-                sampleItem = arrayData[0]
-                break
-              }
+            const loopNodeResult = lastExecutionResults[sourceNode.id]
+            if (loopNodeResult?.loop?.[itemVariable] !== undefined) {
+              sampleItem = loopNodeResult.loop[itemVariable]
+            } else if (loopNodeResult?._items && Array.isArray(loopNodeResult._items) && loopNodeResult._items.length > 0) {
+              // Fallback to _items array if loop output not available
+              sampleItem = loopNodeResult._items[0]
             }
 
+            // Show the user-configured variable names as primary access methods
             const loopFields: FieldInfo[] = [
               {
-                path: 'currentIndex',
-                type: 'number',
+                path: itemVariable,
+                type: sampleItem !== null && typeof sampleItem === 'object' ? 'object' : 'any',
                 nodeName,
                 nodeId: sourceNode.id,
-                displayName: 'currentIndex',
+                displayName: `${itemVariable} (current item)`,
                 isDirectUpstream,
                 targetInput,
+                children: sampleItem !== null && typeof sampleItem === 'object'
+                  ? extractFieldsFromData(sampleItem, itemVariable, nodeName, sourceNode.id, 0, isDirectUpstream, targetInput)
+                  : undefined,
               },
               {
-                path: 'currentItem',
-                type: sampleItem !== null && typeof sampleItem === 'object' ? 'object' : 'any',
+                path: indexVariable,
+                type: 'number',
                 nodeName,
                 nodeId: sourceNode.id,
-                displayName: 'currentItem',
+                displayName: `${indexVariable} (current index)`,
                 isDirectUpstream,
                 targetInput,
-                children: sampleItem !== null && typeof sampleItem === 'object'
-                  ? extractFieldsFromData(sampleItem, 'currentItem', nodeName, sourceNode.id, 0, isDirectUpstream, targetInput)
-                  : undefined,
               },
               {
                 path: 'totalItems',
@@ -1680,6 +1739,24 @@ function WorkflowEditorInner() {
                 isDirectUpstream,
                 targetInput,
               },
+              {
+                path: 'isFirst',
+                type: 'boolean',
+                nodeName,
+                nodeId: sourceNode.id,
+                displayName: 'isFirst',
+                isDirectUpstream,
+                targetInput,
+              },
+              {
+                path: 'isLast',
+                type: 'boolean',
+                nodeName,
+                nodeId: sourceNode.id,
+                displayName: 'isLast',
+                isDirectUpstream,
+                targetInput,
+              },
             ]
 
             fields.push(...loopFields)
@@ -1768,6 +1845,17 @@ function WorkflowEditorInner() {
           />
         )}
 
+        {/* Execution List Panel - left side panel for browsing past executions */}
+        {id && showExecutionList && (
+          <ExecutionListPanel
+            workflowId={id}
+            isOpen={showExecutionList}
+            onClose={() => setShowExecutionList(false)}
+            onViewExecution={handleViewExecution}
+            onPinExecution={handlePinExecution}
+          />
+        )}
+
         {/* Flow canvas */}
         <div className={`flex-1 overflow-hidden ${(showExecutionPanel || isViewingExecution || pinnedExecution) ? 'w-2/3' : 'w-full'}`}>
           <ReactFlow
@@ -1879,17 +1967,6 @@ function WorkflowEditorInner() {
             }}
           />
         )}
-
-        {/* Execution List Panel - side panel for browsing past executions */}
-        {id && showExecutionList && (
-          <ExecutionListPanel
-            workflowId={id}
-            isOpen={showExecutionList}
-            onClose={() => setShowExecutionList(false)}
-            onViewExecution={handleViewExecution}
-            onPinExecution={handlePinExecution}
-          />
-        )}
       </div>
 
       {/* Delete confirmation modal */}