From 5d4d60bb95d5b550c5730991eef8ab08d7f45ba0 Mon Sep 17 00:00:00 2001 From: plind <59729252+plind-dm@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:01:19 +0900 Subject: [PATCH] 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 Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../__tests__/loop-result-panel.spec.tsx | 126 ++++++++++++++++++ .../run/loop-log/loop-result-panel.tsx | 30 +++-- .../iteration/__tests__/index.spec.ts | 57 ++++++-- .../run/utils/format-log/iteration/index.ts | 22 ++- .../format-log/loop/__tests__/index.spec.ts | 46 ++++++- .../run/utils/format-log/loop/index.ts | 43 +++++- web/eslint-suppressions.json | 5 - 7 files changed, 291 insertions(+), 38 deletions(-) create mode 100644 web/app/components/workflow/run/loop-log/__tests__/loop-result-panel.spec.tsx 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