Przeglądaj źródła

feat: surface a stop node's message, and fail loudly when a loop path resolves to nothing

Two changes from the same investigation.

SHOW THE STOP MESSAGE

A run ended by a stop node carries a message exactly the way a failed one
carries an error, and it is the only record of why a run that completed did not
do the rest of its work. It was being thrown away: status is Completed and
error is empty, so the executions list showed nothing at all.

The engine now stores stopped, stopReason and stoppedNodeId on the execution.
The executions summary view had to learn the fields too - the listing reads a
server-side projection, so a field absent from that view never reaches the page
however correct the record is. The page renders the message beside the workflow
name where an error would appear, in a neutral colour rather than red: this
outcome was chosen, not suffered.

That makes a custom end-of-run message possible - put a stop node at the end of
a branch, set its message, and the executions list tells you which way the run
went.

FAIL LOUDLY ON A LOOP PATH THAT RESOLVES TO NOTHING

While diagnosing why an email with a PDF was reported as having no attachments:
the imap-trigger had detected the attachment correctly - hasAttachments was
true in its output - but the loop over the emails never iterated them. Its
inputField did not resolve, and the node responded by wrapping the whole payload
as a single item. The body then ran exactly once with the item variable holding
the entire input instead of an email, so email.hasAttachments was undefined,
the if-condition took the false branch, and the workflow reported no
attachments. Everything downstream was consistent with a wrong path, and
nothing anywhere said the path was wrong.

A named path that resolves to undefined is now an error naming the path, the
keys actually available at that level, and the fact that getFieldValue drops a
leading "data." segment - which is why a field genuinely called data has to be
written as "data.data". A path that resolves to a real non-array value still
gets wrapped as before; that case is a deliberate convenience, not a mistake.

Verified: a stop execution now carries stopReason through the detail endpoint,
the list endpoint and into the rendered page, checked in a browser. Full suite
45/45.
fszontagh 1 miesiąc temu
rodzic
commit
553343c84e

+ 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}