fix(web): assign in-progress tracing items to latest loop/iteration record (#34661)

Co-authored-by: Blackoutta <37723456+Blackoutta@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
plind 2026-04-10 15:01:19 +09:00 committed by GitHub
parent 7f4bf19186
commit 5d4d60bb95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 291 additions and 38 deletions

View File

@ -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 (
<section data-testid="code-editor">
<div>{props.title}</div>
<div>{JSON.stringify(props.value)}</div>
</section>
)
},
}))
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
__esModule: true,
default: (props: { list: NodeTracing[], className?: string }) => {
mockTracingPanel(props)
return <div data-testid="tracing-panel">{props.list.length}</div>
},
}))
const createNodeTracing = (id: string, executionMetadata?: NonNullable<NodeTracing['execution_metadata']>): 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(
<LoopResultPanel
list={[[
createNodeTracing('loop-2-step-1', {
total_tokens: 0,
total_price: 0,
currency: 'USD',
loop_index: 2,
}),
]]}
onBack={vi.fn()}
loopVariableMap={loopVariableMap}
/>,
)
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(
<LoopResultPanel
list={[[
createNodeTracing('parallel-step-1', {
total_tokens: 0,
total_price: 0,
currency: 'USD',
parallel_mode_run_id: 'parallel-1',
}),
]]}
onBack={vi.fn()}
loopVariableMap={loopVariableMap}
/>,
)
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'],
}))
})
})
})

View File

@ -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<Props> = ({
}))
}, [])
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<Props> = ({
return <RiErrorWarningLine className="h-4 w-4 text-text-destructive" />
if (isRunning)
return <RiLoader2Line className="h-3.5 w-3.5 animate-spin text-primary-600" />
return <RiLoader2Line className="text-primary-600 h-3.5 w-3.5 animate-spin" />
return (
<>
{hasDurationMap && (
<div className="system-xs-regular text-text-tertiary">
{countLoopDuration(loop, loopDurationMap)}
{countLoopDuration(loop, index, loopDurationMap)}
</div>
)}
<RiArrowRightSLine
@ -98,7 +108,7 @@ const LoopResultPanel: FC<Props> = ({
<div
className={cn(
'flex w-full cursor-pointer items-center justify-between px-3',
expandedLoops[index] ? 'pb-2 pt-3' : 'py-3',
expandedLoops[index] ? 'pt-3 pb-2' : 'py-3',
'rounded-xl text-left',
)}
onClick={() => toggleLoop(index)}
@ -107,7 +117,7 @@ const LoopResultPanel: FC<Props> = ({
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-[5px] border-divider-subtle bg-util-colors-cyan-cyan-500">
<Loop className="h-3 w-3 text-text-primary-on-surface" />
</div>
<span className="system-sm-semibold-uppercase grow text-text-primary">
<span className="grow system-sm-semibold-uppercase text-text-primary">
{t(`${i18nPrefix}.loop`, { ns: 'workflow' })}
{' '}
{index + 1}
@ -129,14 +139,14 @@ const LoopResultPanel: FC<Props> = ({
)}
>
{
loopVariableMap?.[index] && (
loopVariableMap?.[getLoopRunKey(loop, index)] && (
<div className="p-2 pb-0">
<CodeEditor
readOnly
title={<div>{t('nodes.loop.loopVariables', { ns: 'workflow' }).toLocaleUpperCase()}</div>}
language={CodeLanguage.json}
height={112}
value={loopVariableMap[index]}
value={loopVariableMap[getLoopRunKey(loop, index)]}
isJSONStringifyBeauty
/>
</div>

View File

@ -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])
})
})

View File

@ -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,

View File

@ -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])
})
})

View File

@ -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<string, NodeTracing[]>()
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) || []),
}
}

View File

@ -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