Browse Source

Merge branch 'stop-reason-and-loop-guard'

fszontagh 1 month ago
parent
commit
106b18ecd6

+ 22 - 0
nodes/core/loop.js

@@ -116,12 +116,19 @@ async function execute(config, input, context) {
   // 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;
+  let pathResolutionFailed = false;
   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);
+    // A named path that resolves to nothing is a misconfiguration, not an
+    // empty list. Wrapping the whole payload as a single item here is what
+    // made a wrong path look like a working loop: the body ran exactly once,
+    // the item variable held the entire input instead of an element, and every
+    // field read off it came back undefined.
+    pathResolutionFailed = items === undefined;
   } else {
     // Unexpected type - try to use as-is
     items = inputField;
@@ -133,6 +140,21 @@ async function execute(config, input, context) {
   }
 
   // Validate input
+  if (pathResolutionFailed) {
+    // Note that getFieldValue drops a leading "data." segment, so "data.x"
+    // and "x" mean the same thing and reaching a field genuinely called
+    // "data" needs it written twice.
+    const available = (data && typeof data === 'object' && !Array.isArray(data))
+      ? Object.keys(data).join(', ')
+      : typeof data;
+    throw new Error(
+      'Loop node: nothing found at "' + inputField + '". The loop needs the path of ' +
+      'an array to iterate. Available at the top level here: ' + available + '. ' +
+      'Note a leading "data." is ignored, so a field actually named "data" has to be ' +
+      'written as "data.data".'
+    );
+  }
+
   if (!Array.isArray(items)) {
     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`);

+ 10 - 0
src/runner/workflow_engine.cpp

@@ -155,6 +155,16 @@ nlohmann::json ExecutionResult::toJson() const {
     j["error"] = error;
     j["output"] = truncateLargeValues(final_output);
 
+    // A run ended deliberately by a stop node carries a message the same way a
+    // failed one carries an error. It is the only record of why an execution
+    // that completed did not do the rest of its work, so it is stored even
+    // though the status is Completed and the error is empty.
+    j["stopped"] = stop_requested;
+    if (stop_requested) {
+        j["stopReason"] = stop_reason;
+        j["stoppedNodeId"] = stopped_node_id;
+    }
+
     // A finished execution's node outputs are a log, so large strings are
     // truncated. A Waiting execution's node outputs are the resume state a
     // later resume seeds itself from - truncating them would hand the resume

+ 7 - 1
src/webserver/webserver_service.cpp

@@ -266,9 +266,15 @@ void WebServerService::ensureExecutionsSummaryView() {
         }
     }
 
+    // stopReason belongs here for the same reason error does: it is the message
+    // explaining how a run ended, and the listing is where someone looks for it.
+    // A stopped run has status "completed" and an empty error, so without this
+    // field the list cannot tell a run that finished its work from one that
+    // deliberately ended early.
     auto result = storage_->createView(kExecutionsSummaryView, "executions",
                                        {"workflowId", "workflowName", "status", "triggerType",
-                                        "startedAt", "finishedAt", "error", "runnerId"});
+                                        "startedAt", "finishedAt", "error", "runnerId",
+                                        "stopped", "stopReason", "stoppedNodeId"});
     if (result.failed()) {
         LOG_ERROR("Could not create the executions summary view: {}. The executions "
                   "listing will fail until this is resolved.", result.error().message());

+ 9 - 0
webui/src/api/workflows.ts

@@ -300,6 +300,11 @@ export interface ExecutionDetail {
   finishedAt: number
   nodeExecutions: NodeExecution[]
   error?: string
+  // Set when a stop node ended the run deliberately. The status is "completed"
+  // and error is empty, so this is the only record of why the rest of the
+  // workflow did not run.
+  stopReason?: string
+  stoppedNodeId?: string
   output?: any
   workflowSnapshot?: WorkflowSnapshot
 }
@@ -312,6 +317,7 @@ export interface ExecutionListItem {
   triggerType: string
   runnerId: string
   error: string
+  stopReason?: string
   startedAt: number
   finishedAt: number
 }
@@ -326,6 +332,7 @@ function transformExecutionListItem(data: any): ExecutionListItem {
     triggerType: data.triggerType || '',
     runnerId: data.runnerId || '',
     error: data.error || '',
+    stopReason: data.stopReason || undefined,
     startedAt: data.startedAt,
     finishedAt: data.finishedAt,
   }
@@ -344,6 +351,8 @@ function transformExecutionDetail(data: any): ExecutionDetail {
     finishedAt: data.finishedAt,
     nodeExecutions: data.nodeExecutions || [],
     error: data.error,
+    stopReason: data.stopReason || undefined,
+    stoppedNodeId: data.stoppedNodeId || undefined,
     output: data.output,
     workflowSnapshot: data.workflowSnapshot,
   }

+ 15 - 1
webui/src/pages/ExecutionsPage.tsx

@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query'
 import { executionsApi } from '../api/workflows'
 import { useExecutionListUpdates } from '../hooks/useExecutionListUpdates'
 import { formatDistanceToNow } from 'date-fns'
-import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle } from 'lucide-react'
+import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon } from 'lucide-react'
 import clsx from 'clsx'
 
 // Helper to safely format timestamps that might be invalid or too large
@@ -159,6 +159,20 @@ export default function ExecutionsPage() {
                           <span className="break-words">{execution.error}</span>
                         </div>
                       )}
+                      {/* A run ended by a stop node carries a message the same
+                          way a failed one carries an error, and it is the only
+                          record of why a completed run did not do the rest of
+                          its work. Shown in a neutral colour: this outcome was
+                          chosen, not suffered. */}
+                      {!execution.error && execution.stopReason && (
+                        <div
+                          className="mt-1 flex items-start gap-1.5 text-gray-600 dark:text-gray-400 text-xs max-w-xl"
+                          title={execution.stopReason}
+                        >
+                          <Octagon className="w-3.5 h-3.5 shrink-0 mt-0.5" />
+                          <span className="break-words">{execution.stopReason}</span>
+                        </div>
+                      )}
                     </td>
                     <td className="px-4 py-3 align-top text-gray-600 dark:text-gray-400">
                       {execution.triggerType}