Răsfoiți Sursa

feat: extend the execute API for a per-iteration single-node re-run

executeToNode now accepts an optional targetLoopIteration, sent as
_targetLoopIteration only when given so an engine that predates the
field sees nothing different from before (degrades to iteration 0,
today's behaviour). Added singleNodeIteration to ExecutionDetail and
a forward-compatible, defensively-read sideEffects flag to
NodeDefinition - neither is sent by the backend yet.

Also pulls the loop-membership graph walk (which node belongs to which
loop's body) out of ExecutionResultsPanel into a shared utility, since
the editor needs the identical answer to build a re-run's cached
outputs against the right iteration.
fszontagh 1 lună în urmă
părinte
comite
a50855d37f
2 a modificat fișierele cu 73 adăugiri și 3 ștergeri
  1. 23 3
      webui/src/api/workflows.ts
  2. 50 0
      webui/src/utils/loopMembership.ts

+ 23 - 3
webui/src/api/workflows.ts

@@ -70,6 +70,11 @@ export interface NodeDefinition {
   inputs: { name: string; displayName: string; type: string; required: boolean }[]
   outputs: NodeOutput[]
   dynamicOutputs?: DynamicOutputs
+  // Declares that running this node does something the engine can't undo -
+  // an external call, a write, a publish. Not sent by the backend yet (the
+  // module-level field is being built separately); read defensively so the
+  // re-run confirmation can go from generic to specific the day it is.
+  sideEffects?: boolean
 }
 
 // Transform backend workflow data to frontend format
@@ -249,17 +254,27 @@ export const workflowsApi = {
 
   // Execute workflow up to a specific node (for testing single nodes)
   // cachedOutputs: Map of nodeId -> { output, configHash } for upstream nodes with valid cached data
+  // targetLoopIteration: the 0-based loop iteration to run the target node
+  // as, when it lives inside a loop body. Omitted entirely (not sent as
+  // undefined) when not given, so an engine that predates this field sees
+  // nothing different from before - it degrades to iteration 0, today's
+  // behaviour, rather than erroring on an unknown field.
   executeToNode: async (
     id: string,
     targetNodeId: string,
     triggerData?: any,
-    cachedOutputs?: Record<string, { output: any; configHash: string }>
+    cachedOutputs?: Record<string, { output: any; configHash: string }>,
+    targetLoopIteration?: number
   ) => {
-    const response = await api.post(`/workflows/${id}/execute`, {
+    const payload: Record<string, any> = {
       ...triggerData,
       _targetNodeId: targetNodeId,
       _cachedOutputs: cachedOutputs,
-    })
+    }
+    if (targetLoopIteration !== undefined) {
+      payload._targetLoopIteration = targetLoopIteration
+    }
+    const response = await api.post(`/workflows/${id}/execute`, payload)
     return response.data
   },
 
@@ -503,6 +518,11 @@ export interface ExecutionDetail {
   // absent entirely on executions recorded before the engine started sending
   // it - treat its absence as "not a single-node test", never as an error.
   singleNodeTarget?: string
+  // The loop iteration a single-node test targeted, when singleNodeTarget
+  // ran inside a loop. Absent means iteration 0 (today's behaviour) or an
+  // engine that doesn't send the field yet - both read the same way, so
+  // this is never treated as an error condition.
+  singleNodeIteration?: number
 }
 
 export interface ExecutionListItem {

+ 50 - 0
webui/src/utils/loopMembership.ts

@@ -0,0 +1,50 @@
+import { Node, Edge } from 'reactflow'
+
+/**
+ * Maps every node inside a loop body to the id of the loop node that owns
+ * it. A node not reachable from any loop node's 'loop' output is absent
+ * from the map, including loop nodes themselves - a loop node is not a
+ * member of its own body.
+ *
+ * Shared by ExecutionResultsPanel (grouping loop-body results by iteration)
+ * and WorkflowEditorPage (deciding which cached upstream output belongs to
+ * the same iteration as a node being re-run). Both need the identical
+ * answer to "is this node inside this loop's body", so this is the one
+ * place that walks the graph to find out.
+ */
+export function computeNodeToLoopMap(nodes: Node[], edges: Edge[]): Map<string, string> {
+  const nodeToLoopMap = new Map<string, string>()
+  const loopNodes = nodes.filter(n => n.data?.type === 'loop')
+
+  const outgoingEdges = new Map<string, Array<{ target: string; handle: string }>>()
+  nodes.forEach(n => outgoingEdges.set(n.id, []))
+  edges.forEach(e => {
+    outgoingEdges.get(e.source)?.push({ target: e.target, handle: e.sourceHandle || 'main' })
+  })
+
+  const findLoopBodyNodes = (loopId: string, nodeId: string, visited: Set<string>) => {
+    if (visited.has(nodeId)) return
+    visited.add(nodeId)
+
+    if (nodeId !== loopId) {
+      nodeToLoopMap.set(nodeId, loopId)
+    }
+
+    const outEdges = outgoingEdges.get(nodeId) || []
+    for (const edge of outEdges) {
+      if (nodeId === loopId && edge.handle === 'done') continue
+      const targetNode = nodes.find(n => n.id === edge.target)
+      if (targetNode?.data?.type === 'loop' && edge.target !== loopId) continue
+      findLoopBodyNodes(loopId, edge.target, visited)
+    }
+  }
+
+  loopNodes.forEach(loopNode => {
+    const outEdges = outgoingEdges.get(loopNode.id) || []
+    const loopEdges = outEdges.filter(e => e.handle === 'loop')
+    const visited = new Set<string>()
+    loopEdges.forEach(edge => findLoopBodyNodes(loopNode.id, edge.target, visited))
+  })
+
+  return nodeToLoopMap
+}