diff --git a/web/app/components/workflow/run/loop-log/__tests__/loop-result-panel.spec.tsx b/web/app/components/workflow/run/loop-log/__tests__/loop-result-panel.spec.tsx
new file mode 100644
index 0000000000..9c2f74a02b
--- /dev/null
+++ b/web/app/components/workflow/run/loop-log/__tests__/loop-result-panel.spec.tsx
@@ -0,0 +1,126 @@
+import type { ReactNode } from 'react'
+import type { LoopVariableMap, NodeTracing } from '@/types/workflow'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { BlockEnum } from '../../../types'
+import LoopResultPanel from '../loop-result-panel'
+
+const mockCodeEditor = vi.hoisted(() => vi.fn())
+const mockTracingPanel = vi.hoisted(() => vi.fn())
+
+vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
+ __esModule: true,
+ default: (props: { title: ReactNode, value: unknown }) => {
+ mockCodeEditor(props)
+ return (
+
+ {props.title}
+ {JSON.stringify(props.value)}
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
+ __esModule: true,
+ default: (props: { list: NodeTracing[], className?: string }) => {
+ mockTracingPanel(props)
+ return
{props.list.length}
+ },
+}))
+
+const createNodeTracing = (id: string, executionMetadata?: NonNullable): NodeTracing => ({
+ id,
+ index: 0,
+ predecessor_node_id: '',
+ node_id: `node-${id}`,
+ node_type: BlockEnum.Code,
+ title: `Node ${id}`,
+ inputs: {},
+ inputs_truncated: false,
+ process_data: {},
+ process_data_truncated: false,
+ outputs: {},
+ outputs_truncated: false,
+ status: 'succeeded',
+ error: '',
+ elapsed_time: 0,
+ execution_metadata: executionMetadata,
+ metadata: {
+ iterator_length: 0,
+ iterator_index: 0,
+ loop_length: 0,
+ loop_index: 0,
+ },
+ created_at: 0,
+ created_by: {
+ id: 'user-1',
+ name: 'Tester',
+ email: 'tester@example.com',
+ },
+ finished_at: 0,
+})
+
+describe('LoopResultPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Loop variables should be resolved by the actual run key, not the rendered row position.
+ describe('Loop Variable Resolution', () => {
+ it('should read loop variables by the actual loop index when rows are compacted', () => {
+ const loopVariableMap: LoopVariableMap = {
+ 2: { item: 'alpha' },
+ }
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('workflow.singleRun.loop 1'))
+
+ expect(screen.getByTestId('code-editor')).toHaveTextContent('{"item":"alpha"}')
+ expect(mockCodeEditor).toHaveBeenCalledWith(expect.objectContaining({
+ value: loopVariableMap[2],
+ }))
+ })
+
+ it('should read loop variables by parallel run id when available', () => {
+ const loopVariableMap: LoopVariableMap = {
+ 'parallel-1': { item: 'beta' },
+ }
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('workflow.singleRun.loop 1'))
+
+ expect(screen.getByTestId('code-editor')).toHaveTextContent('{"item":"beta"}')
+ expect(mockCodeEditor).toHaveBeenCalledWith(expect.objectContaining({
+ value: loopVariableMap['parallel-1'],
+ }))
+ })
+ })
+})
diff --git a/web/app/components/workflow/run/loop-log/loop-result-panel.tsx b/web/app/components/workflow/run/loop-log/loop-result-panel.tsx
index d69ba80e89..b2d627fb01 100644
--- a/web/app/components/workflow/run/loop-log/loop-result-panel.tsx
+++ b/web/app/components/workflow/run/loop-log/loop-result-panel.tsx
@@ -19,6 +19,18 @@ import { cn } from '@/utils/classnames'
const i18nPrefix = 'singleRun'
+const getLoopRunKey = (loop: NodeTracing[], fallbackIndex: number) => {
+ const executionMetadata = loop[0]?.execution_metadata
+
+ if (executionMetadata?.parallel_mode_run_id !== undefined)
+ return executionMetadata.parallel_mode_run_id
+
+ if (executionMetadata?.loop_index !== undefined)
+ return String(executionMetadata.loop_index)
+
+ return String(fallbackIndex)
+}
+
type Props = {
list: NodeTracing[][]
onBack: () => void
@@ -42,10 +54,8 @@ const LoopResultPanel: FC = ({
}))
}, [])
- const countLoopDuration = (loop: NodeTracing[], loopDurationMap: LoopDurationMap): string => {
- const loopRunIndex = loop[0]?.execution_metadata?.loop_index as number
- const loopRunId = loop[0]?.execution_metadata?.parallel_mode_run_id
- const loopItem = loopDurationMap[loopRunId || loopRunIndex]
+ const countLoopDuration = (loop: NodeTracing[], index: number, loopDurationMap: LoopDurationMap): string => {
+ const loopItem = loopDurationMap[getLoopRunKey(loop, index)]
const duration = loopItem
return `${(duration && duration > 0.01) ? duration.toFixed(2) : 0.01}s`
}
@@ -59,13 +69,13 @@ const LoopResultPanel: FC = ({
return
if (isRunning)
- return
+ return
return (
<>
{hasDurationMap && (
- {countLoopDuration(loop, loopDurationMap)}
+ {countLoopDuration(loop, index, loopDurationMap)}
)}
= ({
toggleLoop(index)}
@@ -107,7 +117,7 @@ const LoopResultPanel: FC
= ({
-
+
{t(`${i18nPrefix}.loop`, { ns: 'workflow' })}
{' '}
{index + 1}
@@ -129,14 +139,14 @@ const LoopResultPanel: FC = ({
)}
>
{
- loopVariableMap?.[index] && (
+ loopVariableMap?.[getLoopRunKey(loop, index)] && (
{t('nodes.loop.loopVariables', { ns: 'workflow' }).toLocaleUpperCase()}
}
language={CodeLanguage.json}
height={112}
- value={loopVariableMap[index]}
+ value={loopVariableMap[getLoopRunKey(loop, index)]}
isJSONStringifyBeauty
/>
diff --git a/web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts
index 5b427bd9cf..8f30f6723c 100644
--- a/web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts
+++ b/web/app/components/workflow/run/utils/format-log/iteration/__tests__/index.spec.ts
@@ -1,6 +1,6 @@
import type { NodeTracing } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
-import format from '..'
+import format, { addChildrenToIterationNode } from '..'
import graphToLogStruct from '../../graph-to-log-struct'
describe('iteration', () => {
@@ -9,15 +9,48 @@ describe('iteration', () => {
it('result should have no nodes in iteration node', () => {
expect(result.find(item => !!item.execution_metadata?.iteration_id)).toBeUndefined()
})
- // test('iteration should put nodes in details', () => {
- // expect(result).toEqual([
- // startNode,
- // {
- // ...iterationNode,
- // details: [
- // [iterations[0], iterations[1]],
- // ],
- // },
- // ])
- // })
+
+ it('should place the first child of a new iteration at a new record when its index is missing', () => {
+ const parent = { node_id: 'iter1', node_type: 'iteration', execution_metadata: {} } as unknown as NodeTracing
+ const child0 = { node_id: 'code', execution_metadata: { iteration_id: 'iter1', iteration_index: 0 } } as unknown as NodeTracing
+ const streaming = { node_id: 'code', execution_metadata: { iteration_id: 'iter1' } } as unknown as NodeTracing
+
+ const result = addChildrenToIterationNode(parent, [child0, streaming])
+ expect(result.details![0]).toEqual([child0])
+ expect(result.details![1]).toEqual([streaming])
+ })
+
+ it('should keep missing iteration_index items in the current record when the node has not restarted', () => {
+ const parent = {
+ node_id: 'iter1',
+ node_type: 'iteration',
+ execution_metadata: {
+ iteration_duration_map: { 0: 1.2, 1: 0.4 },
+ },
+ } as unknown as NodeTracing
+ const child0 = { node_id: 'code', execution_metadata: { iteration_id: 'iter1', iteration_index: 0 } } as unknown as NodeTracing
+ const child1 = { node_id: 'code', execution_metadata: { iteration_id: 'iter1', iteration_index: 1 } } as unknown as NodeTracing
+ const streaming = { node_id: 'tool', execution_metadata: { iteration_id: 'iter1' } } as unknown as NodeTracing
+
+ const result = addChildrenToIterationNode(parent, [child0, child1, streaming])
+ expect(result.details![0]).toEqual([child0])
+ expect(result.details![1]).toEqual([child1, streaming])
+ })
+
+ it('should not jump to the latest iteration when an earlier item is missing iteration_index', () => {
+ const parent = {
+ node_id: 'iter1',
+ node_type: 'iteration',
+ execution_metadata: {
+ iteration_duration_map: { 0: 1.2, 1: 0.4 },
+ },
+ } as unknown as NodeTracing
+ const code0 = { node_id: 'code', execution_metadata: { iteration_id: 'iter1', iteration_index: 0 } } as unknown as NodeTracing
+ const tool = { node_id: 'tool', execution_metadata: { iteration_id: 'iter1' } } as unknown as NodeTracing
+ const code1 = { node_id: 'code', execution_metadata: { iteration_id: 'iter1', iteration_index: 1 } } as unknown as NodeTracing
+
+ const result = addChildrenToIterationNode(parent, [code0, tool, code1])
+ expect(result.details![0]).toEqual([code0, tool])
+ expect(result.details![1]).toEqual([code1])
+ })
})
diff --git a/web/app/components/workflow/run/utils/format-log/iteration/index.ts b/web/app/components/workflow/run/utils/format-log/iteration/index.ts
index fbb81118a1..5bd6b822e0 100644
--- a/web/app/components/workflow/run/utils/format-log/iteration/index.ts
+++ b/web/app/components/workflow/run/utils/format-log/iteration/index.ts
@@ -4,15 +4,31 @@ import formatParallelNode from '../parallel'
export function addChildrenToIterationNode(iterationNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing {
const details: NodeTracing[][] = []
- childrenNodes.forEach((item, index) => {
+ let lastResolvedIndex = -1
+
+ childrenNodes.forEach((item) => {
if (!item.execution_metadata)
return
- const { iteration_index = 0 } = item.execution_metadata
- const runIndex: number = iteration_index !== undefined ? iteration_index : index
+ const { iteration_index } = item.execution_metadata
+ let runIndex: number
+
+ if (iteration_index !== undefined) {
+ runIndex = iteration_index
+ }
+ else if (lastResolvedIndex >= 0) {
+ const currentGroup = details[lastResolvedIndex] || []
+ const seenSameNodeInCurrentGroup = currentGroup.some(node => node.node_id === item.node_id)
+ runIndex = seenSameNodeInCurrentGroup ? lastResolvedIndex + 1 : lastResolvedIndex
+ }
+ else {
+ runIndex = 0
+ }
+
if (!details[runIndex])
details[runIndex] = []
details[runIndex].push(item)
+ lastResolvedIndex = runIndex
})
return {
...iterationNode,
diff --git a/web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts
index f352598943..00380361ed 100644
--- a/web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts
+++ b/web/app/components/workflow/run/utils/format-log/loop/__tests__/index.spec.ts
@@ -1,6 +1,6 @@
import type { NodeTracing } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
-import format from '..'
+import format, { addChildrenToLoopNode } from '..'
import graphToLogStruct from '../../graph-to-log-struct'
describe('loop', () => {
@@ -21,4 +21,48 @@ describe('loop', () => {
},
])
})
+
+ it('should place the first child of a new loop run at a new record when its index is missing', () => {
+ const parent = { node_id: 'loop1', node_type: 'loop', execution_metadata: {} } as unknown as NodeTracing
+ const child0 = { node_id: 'code', execution_metadata: { loop_id: 'loop1', loop_index: 0 } } as unknown as NodeTracing
+ const streaming = { node_id: 'code', execution_metadata: { loop_id: 'loop1' } } as unknown as NodeTracing
+
+ const result = addChildrenToLoopNode(parent, [child0, streaming])
+ expect(result.details![0]).toEqual([child0])
+ expect(result.details![1]).toEqual([streaming])
+ })
+
+ it('should keep missing loop_index items in the current record when the node has not restarted', () => {
+ const parent = {
+ node_id: 'loop1',
+ node_type: 'loop',
+ execution_metadata: {
+ loop_duration_map: { 0: 1.2, 1: 0.4 },
+ },
+ } as unknown as NodeTracing
+ const child0 = { node_id: 'code', execution_metadata: { loop_id: 'loop1', loop_index: 0 } } as unknown as NodeTracing
+ const child1 = { node_id: 'code', execution_metadata: { loop_id: 'loop1', loop_index: 1 } } as unknown as NodeTracing
+ const streaming = { node_id: 'tool', execution_metadata: { loop_id: 'loop1' } } as unknown as NodeTracing
+
+ const result = addChildrenToLoopNode(parent, [child0, child1, streaming])
+ expect(result.details![0]).toEqual([child0])
+ expect(result.details![1]).toEqual([child1, streaming])
+ })
+
+ it('should not jump to the latest loop when an earlier item is missing loop_index', () => {
+ const parent = {
+ node_id: 'loop1',
+ node_type: 'loop',
+ execution_metadata: {
+ loop_duration_map: { 0: 1.2, 1: 0.4 },
+ },
+ } as unknown as NodeTracing
+ const code0 = { node_id: 'code', execution_metadata: { loop_id: 'loop1', loop_index: 0 } } as unknown as NodeTracing
+ const tool = { node_id: 'tool', execution_metadata: { loop_id: 'loop1' } } as unknown as NodeTracing
+ const code1 = { node_id: 'code', execution_metadata: { loop_id: 'loop1', loop_index: 1 } } as unknown as NodeTracing
+
+ const result = addChildrenToLoopNode(parent, [code0, tool, code1])
+ expect(result.details![0]).toEqual([code0, tool])
+ expect(result.details![1]).toEqual([code1])
+ })
})
diff --git a/web/app/components/workflow/run/utils/format-log/loop/index.ts b/web/app/components/workflow/run/utils/format-log/loop/index.ts
index fd26c3916e..be77626786 100644
--- a/web/app/components/workflow/run/utils/format-log/loop/index.ts
+++ b/web/app/components/workflow/run/utils/format-log/loop/index.ts
@@ -3,20 +3,49 @@ import { BlockEnum } from '@/app/components/workflow/types'
import formatParallelNode from '../parallel'
export function addChildrenToLoopNode(loopNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing {
- const details: NodeTracing[][] = []
+ const detailsByKey = new Map()
+ let lastResolvedIndex = -1
+ const order: string[] = []
+
+ const ensureGroup = (key: string) => {
+ const group = detailsByKey.get(key)
+ if (group)
+ return group
+
+ const newGroup: NodeTracing[] = []
+ detailsByKey.set(key, newGroup)
+ order.push(key)
+ return newGroup
+ }
+
childrenNodes.forEach((item) => {
if (!item.execution_metadata)
return
- const { parallel_mode_run_id, loop_index = 0 } = item.execution_metadata
- const runIndex: number = (parallel_mode_run_id || loop_index) as number
- if (!details[runIndex])
- details[runIndex] = []
+ const { parallel_mode_run_id, loop_index } = item.execution_metadata
+ let runIndex: number | string
- details[runIndex].push(item)
+ if (parallel_mode_run_id !== undefined) {
+ runIndex = parallel_mode_run_id
+ }
+ else if (loop_index !== undefined) {
+ runIndex = loop_index
+ }
+ else if (lastResolvedIndex >= 0) {
+ const currentGroup = detailsByKey.get(String(lastResolvedIndex)) || []
+ const seenSameNodeInCurrentGroup = currentGroup.some(node => node.node_id === item.node_id)
+ runIndex = seenSameNodeInCurrentGroup ? lastResolvedIndex + 1 : lastResolvedIndex
+ }
+ else {
+ runIndex = 0
+ }
+
+ ensureGroup(String(runIndex)).push(item)
+ if (typeof runIndex === 'number')
+ lastResolvedIndex = runIndex
})
return {
...loopNode,
- details,
+ details: order.map(key => detailsByKey.get(key) || []),
}
}
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index 568f113499..d66447c274 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -11073,11 +11073,6 @@
"count": 1
}
},
- "app/components/workflow/run/loop-log/loop-result-panel.tsx": {
- "tailwindcss/enforce-consistent-class-order": {
- "count": 3
- }
- },
"app/components/workflow/run/loop-result-panel.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 4