Răsfoiți Sursa

feat: fold a run's sub-workflows into it in the executions list

A pipeline that calls six workflows took seven rows, interleaved with
everything else by start time, and reading the list meant reconstructing which
run belonged to which. Sub-runs are now folded into the run that started them,
with a chevron to open the group and a badge saying how many are inside.

The link existed only as triggerData._calledBy, which the listing never sees -
it reads a server-side summary view and does not load trigger data. So the
engine now records parentExecutionId as a field of its own and the view carries
it. It is written only when a run was called by another; absence is what "top
level" means, so every execution recorded before today still reads as top level
and the existing history looks exactly as it did.

Nesting is recursive, because a called workflow can call another - which is
what your anime pipeline does, three deep.

Two things worth knowing about how the children are fetched.

They are asked for by the time window the page covers rather than by the ids on
it. Grouping only what landed on the page does not work: a child starts after
its parent, so newest-first puts children ABOVE their parent, and a parent at
the top of one page has its children at the bottom of the previous one.

The obvious query - parentExecutionId in (id, id, ...) - comes back empty,
although each id on its own matches, and the same "in" works on status and on
workflowId. What is different about parentExecutionId is that most executions
do not have the field at all. That looks like an upstream bug with "in" on a
sparse field and is worth reporting; this avoids the operator rather than
building on it, asking instead for runs whose triggerType says they were
started by another workflow, which every record carries.

Verified in a browser on a real three-level tree: top -> middle -> two leaves,
each level expanding, the badge marking a group whose child failed. tsc, eslint
and the build are clean.
fszontagh 1 lună în urmă
părinte
comite
5ed3edcf50

+ 16 - 0
src/runner/workflow_engine.cpp

@@ -232,6 +232,13 @@ nlohmann::json ExecutionResult::toJson() const {
     // purpose, and nothing went wrong". A failed run says so through its status
     // and error, which carry the same message.
     const bool stopped_cleanly = stop_requested && status != ExecutionStatus::Failed;
+    // Only when this run was called by another. Written as an absent field
+    // rather than an empty string so an old record, which has neither, reads
+    // the same as a new top-level run.
+    if (!parent_execution_id.empty()) {
+        j["parentExecutionId"] = parent_execution_id;
+    }
+
     j["stopped"] = stopped_cleanly;
     if (stopped_cleanly) {
         j["stopReason"] = stop_reason;
@@ -455,6 +462,14 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
         actual_trigger_data.erase("_assignedExecutionId");
     }
 
+    // Who called this run. Left in the trigger data as well - a workflow may
+    // legitimately read it - but lifted onto the record so the listing can
+    // group by it without loading every execution whole.
+    std::string parent_execution_id;
+    if (actual_trigger_data.contains("_calledBy")) {
+        parent_execution_id = actual_trigger_data.value("_calledBy", "");
+    }
+
     if (actual_trigger_data.contains("_resumeExecutionId")) {
         resume_execution_id = actual_trigger_data["_resumeExecutionId"].get<std::string>();
         actual_trigger_data.erase("_resumeExecutionId");
@@ -467,6 +482,7 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
     result.workflow_id = workflow.id;
     result.workflow_name = workflow.name;
     result.status = ExecutionStatus::Running;
+    result.parent_execution_id = parent_execution_id;
     result.trigger_type = trigger_type;
     result.trigger_data = actual_trigger_data;
     result.started_at = TimeUtils::nowMs();

+ 9 - 0
src/runner/workflow_engine.hpp

@@ -146,6 +146,15 @@ struct ExecutionResult {
     nlohmann::json explicit_output;
     bool has_explicit_output = false;
 
+    // The run that called this one, when a Call Workflow node started it.
+    // Empty for a run somebody or a trigger started directly.
+    //
+    // Recorded as a field of its own rather than left in the trigger data,
+    // where it already sits as "_calledBy": the executions listing reads a
+    // server-side summary view and never loads trigger data, so a link buried
+    // there cannot be seen by the one place that wants to group by it.
+    std::string parent_execution_id;
+
     std::string paused_node_id;        // Node that asked to pause, when Waiting
     std::string pause_token;           // Must be presented to resume
     int64_t pause_expires_at = 0;      // Milliseconds since the epoch, 0 for never

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

@@ -240,6 +240,20 @@ void ExecutionController::listExecutions(const httplib::Request& req, httplib::R
         }
     }
 
+    // The runs a given execution started. Asked for by the listing when someone
+    // opens a group, rather than grouping whatever happened to land on the
+    // current page - a parent and its children can straddle a page boundary,
+    // and then the same run would look different depending on where the
+    // pagination fell.
+    // Accepts several ids, comma separated, so the listing can ask for the
+    // children of every run on the page in one request. Grouping only what
+    // happened to land on the page does not work: a child starts after its
+    // parent, so newest-first puts children ABOVE the parent, and a parent at
+    // the top of one page has its children at the bottom of the previous one.
+    if (req.has_param("parentExecutionId")) {
+        addSetFilter(options, "parentExecutionId", req.get_param_value("parentExecutionId"));
+    }
+
     // status and triggerType accept a comma separated list, because the useful
     // question on this listing is "show me failed and cancelled", not one at a
     // time.

+ 6 - 1
src/webserver/webserver_service.cpp

@@ -538,7 +538,12 @@ void WebServerService::ensureExecutionsSummaryView() {
                                         "startedAt", "finishedAt", "error", "runnerId",
                                         "stopped", "stopReason", "stoppedNodeId",
                                         "toleratedErrorCount",
-                                        "pausedNodeId", "pauseExpiresAt"});
+                                        "pausedNodeId", "pauseExpiresAt",
+                                        // Which run called this one, so the
+                                        // executions listing can group a run
+                                        // with the sub-workflows it started
+                                        // without loading every record whole.
+                                        "parentExecutionId"});
     if (result.failed()) {
         LOG_ERROR("Could not create the executions summary view: {}. The executions "
                   "listing will fail until this is resolved.", result.error().message());

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

@@ -536,6 +536,13 @@ export interface ExecutionListItem {
   stopReason?: string
   startedAt: number
   finishedAt: number
+  /**
+   * The run that called this one, when a Call Workflow node started it.
+   * Absent for a run somebody or a trigger started directly - and absent on
+   * every execution recorded before this field existed, which is why the
+   * listing treats "absent" as top level rather than filtering on it.
+   */
+  parentExecutionId?: string
 }
 
 // Transform backend execution list item to frontend format. Spread first, as
@@ -548,6 +555,7 @@ function transformExecutionListItem(data: any): ExecutionListItem {
     runnerId: data.runnerId || '',
     error: data.error || '',
     stopReason: data.stopReason || undefined,
+    parentExecutionId: data.parentExecutionId || undefined,
   }
 }
 
@@ -577,6 +585,8 @@ export interface ExecutionQuery {
   startedBefore?: number
   sortBy?: 'startedAt' | 'finishedAt'
   sortOrder?: 'asc' | 'desc'
+  /** The runs one execution started. Used to open a group, not to filter the page. */
+  parentExecutionId?: string
   page?: number
   pageSize?: number
 }
@@ -628,6 +638,9 @@ export const executionsApi = {
     if (query.startedBefore) params.startedBefore = query.startedBefore
     if (query.sortBy) params.sortBy = query.sortBy
     if (query.sortOrder) params.sortOrder = query.sortOrder
+    // Asking for one run's children, which is a different question from any of
+    // the filters above - it is how a group is opened.
+    if (query.parentExecutionId) params.parentExecutionId = query.parentExecutionId
 
     const response = await api.get('/executions', { params })
     return {

+ 148 - 4
webui/src/pages/ExecutionsPage.tsx

@@ -6,7 +6,7 @@ import { executionsApi, workflowsApi, nodesApi, type ExecutionListItem } from '.
 import { useExecutionListUpdates } from '../hooks/useExecutionListUpdates'
 import { toMillis } from '../utils/timestamps'
 import { formatDistanceToNow } from 'date-fns'
-import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon, Square, Hourglass, Search, X } from 'lucide-react'
+import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, ChevronDown, AlertCircle, Octagon, Square, Hourglass, Search, X, CornerDownRight } from 'lucide-react'
 import clsx from 'clsx'
 
 // Helper to safely format timestamps that might be invalid or too large
@@ -274,6 +274,106 @@ export default function ExecutionsPage() {
     return [...rows].sort((a, b) => (durationOf(a, now) - durationOf(b, now)) * direction)
   }, [data, sort, now])
 
+  // Sub-workflow runs are folded into the run that started them, so a pipeline
+  // that calls six workflows takes one row rather than seven.
+  //
+  // The children of every run on this page are fetched in one request rather
+  // than grouped from what the page happens to contain. A child starts after
+  // its parent, so newest-first puts children above the parent - and a parent
+  // at the top of one page has its children at the bottom of the previous one.
+  const [expanded, setExpanded] = useState<Set<string>>(new Set())
+
+  const topLevel = useMemo(
+    // An execution recorded before parentExecutionId existed has no such field,
+    // and reads as top level. That is deliberate: the alternative is a
+    // server-side filter that would hide every run in the existing history.
+    () => executions.filter((e) => !e.parentExecutionId),
+    [executions]
+  )
+
+  // Asked for by the time window this page covers, not by a list of parent ids.
+  //
+  // The obvious query - parentExecutionId in (id, id, ...) - comes back empty,
+  // although each id on its own matches. The same "in" works on status and on
+  // workflowId, which the listing already scopes itself with; what is different
+  // about parentExecutionId is that most executions do not have the field at
+  // all. Rather than build on an operator that behaves differently on a sparse
+  // field, this asks for the runs that WERE started by another workflow -
+  // triggerType is on every record - within the window the page covers, and
+  // does the grouping here.
+  const childWindow = useMemo(() => {
+    if (topLevel.length === 0) return null
+    let from = Infinity
+    let to = 0
+    for (const row of topLevel) {
+      const started = toMillis(row.startedAt) ?? 0
+      const finished = toMillis(row.finishedAt) || now
+      if (started > 0) from = Math.min(from, started)
+      to = Math.max(to, finished)
+    }
+    return from === Infinity ? null : { from, to }
+    // `now` deliberately absent: a still-running parent would otherwise move
+    // the window every second and refetch the children with it.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [topLevel])
+
+  const { data: childRows } = useQuery({
+    queryKey: ['executions', 'children', childWindow?.from, childWindow?.to],
+    queryFn: () =>
+      executionsApi.query({
+        triggerType: ['workflow', 'error-workflow'],
+        startedAfter: childWindow!.from,
+        startedBefore: childWindow!.to,
+        pageSize: 200,
+      }),
+    enabled: !!childWindow,
+  })
+
+  // Grouped by parent, and by their own parent too - a called workflow can call
+  // another, which is what makes this a tree rather than one level of nesting.
+  const childrenByParent = useMemo(() => {
+    const map = new Map<string, ExecutionListItem[]>()
+    for (const child of childRows?.executions || []) {
+      if (!child.parentExecutionId) continue
+      const list = map.get(child.parentExecutionId) || []
+      list.push(child)
+      map.set(child.parentExecutionId, list)
+    }
+    // Newest first, matching the page's own order.
+    for (const list of map.values()) {
+      list.sort((a, b) => toMillis(b.startedAt)! - toMillis(a.startedAt)!)
+    }
+    return map
+  }, [childRows])
+
+  // One flat list the table can render, carrying how deep each row sits.
+  const visibleRows = useMemo(() => {
+    const out: { execution: ExecutionListItem; depth: number; childCount: number }[] = []
+    const walk = (execution: ExecutionListItem, depth: number, seen: Set<string>) => {
+      // A cycle cannot happen through Call Workflow, which refuses to nest more
+      // than a fixed depth - but a walk that trusted the data to be acyclic
+      // would hang the page rather than show a wrong row.
+      if (seen.has(execution.id)) return
+      seen.add(execution.id)
+      const children = childrenByParent.get(execution.id) || []
+      out.push({ execution, depth, childCount: children.length })
+      if (expanded.has(execution.id)) {
+        for (const child of children) walk(child, depth + 1, seen)
+      }
+    }
+    for (const row of topLevel) walk(row, 0, new Set())
+    return out
+  }, [topLevel, childrenByParent, expanded])
+
+  const toggleExpanded = (id: string) => {
+    setExpanded((previous) => {
+      const next = new Set(previous)
+      if (next.has(id)) next.delete(id)
+      else next.add(id)
+      return next
+    })
+  }
+
   const toggleStatus = (status: string) => {
     const next = statuses.includes(status)
       ? statuses.filter(s => s !== status)
@@ -510,18 +610,44 @@ export default function ExecutionsPage() {
                 </tr>
               </thead>
               <tbody className="divide-y divide-gray-200 dark:divide-slate-700">
-                {executions.map(execution => {
+                {visibleRows.map(({ execution, depth, childCount }) => {
                   const startedAt = toMillis(execution.startedAt)
                   const finishedAt = toMillis(execution.finishedAt)
+                  const isOpen = expanded.has(execution.id)
                   return (
                   <tr
                     key={execution.id}
                     onClick={() => openExecution(execution)}
                     title="Open this execution in the workflow editor"
-                    className="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer"
+                    className={clsx(
+                      'hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer',
+                      depth > 0 && 'bg-gray-50/60 dark:bg-slate-900/40'
+                    )}
                   >
                     <td className="px-4 py-3 align-top">
-                      <div className="flex items-center gap-2">
+                      <div
+                        className="flex items-center gap-2"
+                        style={{ paddingLeft: depth * 18 }}
+                      >
+                        {childCount > 0 ? (
+                          <button
+                            onClick={(e) => {
+                              // The row itself opens the execution, so the
+                              // control that opens the group must not do both.
+                              e.stopPropagation()
+                              toggleExpanded(execution.id)
+                            }}
+                            title={isOpen ? 'Hide the runs this one started' : `Show the ${childCount} run(s) this one started`}
+                            className="p-0.5 -ml-1 rounded text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-slate-700"
+                          >
+                            {isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
+                          </button>
+                        ) : (
+                          <span className="inline-block w-4" />
+                        )}
+                        {depth > 0 && (
+                          <CornerDownRight className="w-3.5 h-3.5 text-gray-400 shrink-0" />
+                        )}
                         {getStatusIcon(execution.status)}
                         <span
                           className={clsx(
@@ -537,6 +663,24 @@ export default function ExecutionsPage() {
                       <span className="font-medium text-gray-900 dark:text-gray-100">
                         {execution.workflowName || execution.workflowId}
                       </span>
+                      {/* What a closed group holds, so it does not have to be
+                          opened to find out whether anything is in it - and so
+                          a failure inside is not hidden behind a chevron that
+                          looks like every other chevron. */}
+                      {childCount > 0 && !isOpen && (
+                        <span
+                          className={clsx(
+                            'ml-2 px-2 py-0.5 rounded-full text-xs font-medium',
+                            (childrenByParent.get(execution.id) || []).some((c) => c.status === 'failed')
+                              ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
+                              : 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300'
+                          )}
+                        >
+                          {(childrenByParent.get(execution.id) || []).some((c) => c.status === 'failed')
+                            ? `${childCount} sub-run${childCount === 1 ? '' : 's'}, one failed`
+                            : `${childCount} sub-run${childCount === 1 ? '' : 's'}`}
+                        </span>
+                      )}
                       <div className="text-xs text-gray-400 dark:text-gray-500 font-mono">
                         {execution.id}
                       </div>