diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_execution_thunk.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_execution_thunk.ts index 8afa9ed57665c..abdf14d7c50a6 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_execution_thunk.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_execution_thunk.ts @@ -32,8 +32,10 @@ export const loadExecutionThunk = createAsyncThunk< try { const previousExecution = getState().detail.execution; - // Make the API call to load the execution - const response = await http.get(`/api/workflowExecutions/${id}`); + // Make the API call to load the execution (without input/output to reduce payload during polling) + const response = await http.get(`/api/workflowExecutions/${id}`, { + query: { includeInput: false, includeOutput: false }, + }); dispatch(setExecution(response)); if (id !== previousExecution?.id) { diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.test.ts b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.test.ts new file mode 100644 index 0000000000000..19d25431289c4 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { ExecutionStatus } from '@kbn/workflows'; +import { useStepExecution } from './use_step_execution'; +import { useKibana } from '../../../hooks/use_kibana'; + +jest.mock('../../../hooks/use_kibana'); +const mockUseKibana = useKibana as jest.MockedFunction; + +const createWrapper = (queryClient: QueryClient) => { + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + return Wrapper; +}; + +describe('useStepExecution', () => { + let mockHttpGet: jest.Mock; + let queryClient: QueryClient; + + const stepResponse = { + stepId: 'step-1', + status: 'completed', + input: { arg: 'value' }, + output: { result: 'ok' }, + }; + + beforeEach(() => { + jest.useFakeTimers(); + mockHttpGet = jest.fn().mockResolvedValue(stepResponse); + mockUseKibana.mockReturnValue({ + services: { http: { get: mockHttpGet } }, + } as any); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + queryClient.clear(); + }); + + it('should not fetch when stepExecutionId is undefined', () => { + const { result } = renderHook( + () => useStepExecution('exec-1', undefined, ExecutionStatus.COMPLETED), + { wrapper: createWrapper(queryClient) } + ); + + expect(result.current.isFetching).toBe(false); + expect(mockHttpGet).not.toHaveBeenCalled(); + }); + + it('should fetch when both IDs are provided', async () => { + const { result } = renderHook( + () => useStepExecution('exec-1', 'step-doc-1', ExecutionStatus.COMPLETED), + { wrapper: createWrapper(queryClient) } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockHttpGet).toHaveBeenCalledWith('/api/workflowExecutions/exec-1/steps/step-doc-1'); + expect(result.current.data).toEqual(stepResponse); + }); + + it('should set staleTime to Infinity for terminal step status', async () => { + const { result } = renderHook( + () => useStepExecution('exec-1', 'step-doc-1', ExecutionStatus.COMPLETED), + { wrapper: createWrapper(queryClient) } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const cachedQuery = queryClient.getQueryCache().findAll({ + queryKey: ['stepExecution', 'exec-1', 'step-doc-1'], + })[0]; + expect(cachedQuery.state.isInvalidated).toBe(false); + expect(cachedQuery.state.dataUpdateCount).toBe(1); + + // After initial fetch, no refetch should happen even after the polling interval + mockHttpGet.mockClear(); + jest.advanceTimersByTime(10_000); + expect(mockHttpGet).not.toHaveBeenCalled(); + }); + + it('should poll for non-terminal step status', async () => { + const { result } = renderHook( + () => useStepExecution('exec-1', 'step-doc-1', ExecutionStatus.RUNNING), + { wrapper: createWrapper(queryClient) } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockHttpGet).toHaveBeenCalledTimes(1); + + // Advance past the 5s refetch interval — should trigger another fetch + mockHttpGet.mockClear(); + jest.advanceTimersByTime(5_000); + await waitFor(() => expect(mockHttpGet).toHaveBeenCalled()); + }); + + it('should stop polling when step transitions to terminal status', async () => { + const { result, rerender } = renderHook( + ({ status }: { status: ExecutionStatus }) => useStepExecution('exec-1', 'step-doc-1', status), + { + wrapper: createWrapper(queryClient), + initialProps: { status: ExecutionStatus.RUNNING }, + } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Transition to terminal + rerender({ status: ExecutionStatus.COMPLETED }); + + mockHttpGet.mockClear(); + jest.advanceTimersByTime(15_000); + expect(mockHttpGet).not.toHaveBeenCalled(); + }); + + it('should use the correct query key structure', async () => { + renderHook(() => useStepExecution('exec-1', 'step-doc-1', ExecutionStatus.COMPLETED), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => + expect( + queryClient.getQueryCache().findAll({ + queryKey: ['stepExecution', 'exec-1', 'step-doc-1'], + }) + ).toHaveLength(1) + ); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.ts b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.ts index e6b14858e54bf..2f9000cabcdf2 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/model/use_step_execution.ts @@ -7,23 +7,35 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useQuery } from '@kbn/react-query'; -import type { EsWorkflowStepExecution } from '@kbn/workflows'; +import type { EsWorkflowStepExecution, ExecutionStatus } from '@kbn/workflows'; +import { isTerminalStatus } from '@kbn/workflows'; +import { useKibana } from '../../../hooks/use_kibana'; -export function useStepExecution(workflowExecutionId: string, stepExecutionId: string) { +const REFETCH_INTERVAL_MS = 5000; + +/** + * Fetches a single step execution with full data (input/output). + * Polls while the step is still running, stops once it reaches a terminal status. + */ +export function useStepExecution( + workflowExecutionId: string, + stepExecutionId: string | undefined, + stepStatus: ExecutionStatus | undefined +) { const { http } = useKibana().services; + const isStepFinished = stepStatus ? isTerminalStatus(stepStatus) : false; return useQuery({ queryKey: ['stepExecution', workflowExecutionId, stepExecutionId], queryFn: async () => { - const response = await http?.get( + const response = await http.get( `/api/workflowExecutions/${workflowExecutionId}/steps/${stepExecutionId}` ); return response; }, enabled: !!workflowExecutionId && !!stepExecutionId, - staleTime: 5000, // Refresh every 5 seconds for real-time logs - refetchInterval: 5000, // Auto-refresh logs + staleTime: isStepFinished ? Infinity : REFETCH_INTERVAL_MS, // will be cleared when switching to a different execution + refetchInterval: isStepFinished ? false : REFETCH_INTERVAL_MS, }); } diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.test.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.test.tsx new file mode 100644 index 0000000000000..130552471dec6 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { useQueryClient } from '@kbn/react-query'; +import type { WorkflowExecutionDto, WorkflowYaml } from '@kbn/workflows'; +import { ExecutionStatus } from '@kbn/workflows'; +import { WorkflowExecutionDetail } from './workflow_execution_detail'; + +jest.mock('@kbn/react-query', () => ({ + ...jest.requireActual('@kbn/react-query'), + useQueryClient: jest.fn(), +})); +const mockUseQueryClient = useQueryClient as jest.MockedFunction; + +jest.mock('./workflow_execution_panel', () => ({ + WorkflowExecutionPanel: () =>
, +})); + +jest.mock('./workflow_step_execution_details', () => ({ + WorkflowStepExecutionDetails: () =>
, +})); + +jest.mock('../model/use_step_execution', () => ({ + useStepExecution: jest.fn(() => ({ data: undefined, isLoading: false })), +})); + +const mockSetSelectedStepExecution = jest.fn(); +jest.mock('../../../hooks/use_workflow_url_state', () => ({ + useWorkflowUrlState: jest.fn(() => ({ + activeTab: 'executions', + setSelectedStepExecution: mockSetSelectedStepExecution, + selectedStepExecutionId: '__overview', + })), +})); + +const createMockExecution = (id: string): WorkflowExecutionDto => ({ + spaceId: 'default', + id, + status: ExecutionStatus.COMPLETED, + error: null, + isTestRun: false, + startedAt: '2024-01-01T00:00:00Z', + finishedAt: '2024-01-01T00:01:00Z', + workflowId: 'workflow-1', + workflowName: 'Test Workflow', + workflowDefinition: { + version: '1', + name: 'test', + enabled: true, + triggers: [], + steps: [], + } as WorkflowYaml, + stepId: undefined, + stepExecutions: [], + duration: 60000, + triggeredBy: 'manual', + yaml: 'version: "1"', +}); + +const mockUseWorkflowExecutionPolling = jest.fn((_executionId: string) => ({ + workflowExecution: createMockExecution('exec-1'), + isLoading: false, + error: null, +})); +jest.mock('../../../entities/workflows/model/use_workflow_execution_polling', () => ({ + useWorkflowExecutionPolling: (executionId: string) => + mockUseWorkflowExecutionPolling(executionId), +})); + +describe('WorkflowExecutionDetail - cache invalidation', () => { + let mockRemoveQueries: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockRemoveQueries = jest.fn(); + mockUseQueryClient.mockReturnValue({ + removeQueries: mockRemoveQueries, + } as any); + }); + + it('should call removeQueries on unmount with the current execution query key', () => { + const { unmount } = render( + + ); + + expect(mockRemoveQueries).not.toHaveBeenCalled(); + + unmount(); + + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: ['stepExecution', 'exec-1'], + }); + }); + + it('should call removeQueries for the previous execution when executionId changes', () => { + const { rerender } = render( + + ); + + expect(mockRemoveQueries).not.toHaveBeenCalled(); + + mockUseWorkflowExecutionPolling.mockReturnValue({ + workflowExecution: createMockExecution('exec-2'), + isLoading: false, + error: null, + }); + + rerender(); + + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: ['stepExecution', 'exec-1'], + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.tsx index 8ef5dcb0acbcd..9564b07ab315a 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_execution_detail.tsx @@ -11,12 +11,14 @@ import { EuiPanel } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo } from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { useQueryClient } from '@kbn/react-query'; import { ResizableLayout, ResizableLayoutDirection, ResizableLayoutMode, ResizableLayoutOrder, } from '@kbn/resizable-layout'; +import type { WorkflowStepExecutionDto } from '@kbn/workflows'; import { WorkflowExecutionPanel } from './workflow_execution_panel'; import { buildOverviewStepExecutionFromContext, @@ -25,6 +27,7 @@ import { import { WorkflowStepExecutionDetails } from './workflow_step_execution_details'; import { useWorkflowExecutionPolling } from '../../../entities/workflows/model/use_workflow_execution_polling'; import { useWorkflowUrlState } from '../../../hooks/use_workflow_url_state'; +import { useStepExecution } from '../model/use_step_execution'; const WidthStorageKey = 'WORKFLOWS_EXECUTION_DETAILS_WIDTH'; const DefaultSidebarWidth = 300; @@ -36,6 +39,7 @@ export interface WorkflowExecutionDetailProps { export const WorkflowExecutionDetail: React.FC = React.memo( ({ executionId, onClose }) => { const { workflowExecution, error } = useWorkflowExecutionPolling(executionId); + const queryClient = useQueryClient(); const { activeTab, setSelectedStepExecution, selectedStepExecutionId } = useWorkflowUrlState(); const [sidebarWidth = DefaultSidebarWidth, setSidebarWidth] = useLocalStorage( @@ -44,6 +48,13 @@ export const WorkflowExecutionDetail: React.FC = R ); const showBackButton = activeTab === 'executions'; + // Clear cached step I/O data when switching to a different execution + useEffect(() => { + return () => { + queryClient.removeQueries({ queryKey: ['stepExecution', executionId] }); + }; + }, [executionId, queryClient]); + useEffect(() => { if ( !selectedStepExecutionId && // no step execution selected @@ -68,7 +79,26 @@ export const WorkflowExecutionDetail: React.FC = R return null; }, [workflowExecution]); - const selectedStepExecution = useMemo(() => { + // For pseudo-steps (overview, trigger), build from execution context directly + const isPseudoStep = + selectedStepExecutionId === '__overview' || selectedStepExecutionId === 'trigger'; + + // Find the lightweight step from the polled execution (has status/duration but no I/O) + const lightweightStep = useMemo(() => { + if (!selectedStepExecutionId || isPseudoStep) { + return undefined; + } + return workflowExecution?.stepExecutions?.find((step) => step.id === selectedStepExecutionId); + }, [workflowExecution?.stepExecutions, selectedStepExecutionId, isPseudoStep]); + + // Lazy-load full step data (with input/output) for real steps + const { data: fullStepData, isLoading: isLoadingStepData } = useStepExecution( + executionId, + isPseudoStep ? undefined : selectedStepExecutionId ?? undefined, + lightweightStep?.status + ); + + const selectedStepExecution = useMemo(() => { if (!selectedStepExecutionId) { return undefined; } @@ -81,11 +111,17 @@ export const WorkflowExecutionDetail: React.FC = R return buildTriggerStepExecutionFromContext(workflowExecution) ?? undefined; } - if (!workflowExecution?.stepExecutions?.length) { + if (!lightweightStep) { return undefined; } - return workflowExecution.stepExecutions.find((step) => step.id === selectedStepExecutionId); - }, [workflowExecution, selectedStepExecutionId]); + + // Merge: use lightweight step for structure/status, overlay full I/O when available + if (fullStepData) { + return { ...lightweightStep, input: fullStepData.input, output: fullStepData.output }; + } + + return lightweightStep; + }, [workflowExecution, selectedStepExecutionId, lightweightStep, fullStepData]); return ( @@ -110,6 +146,7 @@ export const WorkflowExecutionDetail: React.FC = R workflowExecutionId={executionId} stepExecution={selectedStepExecution} workflowExecutionDuration={workflowExecution?.duration ?? undefined} + isLoadingStepData={isLoadingStepData && !isPseudoStep} /> } minFlexPanelSize={200} diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx index 34a83c24ee06f..c90a91bb764ca 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_details.tsx @@ -30,10 +30,11 @@ interface WorkflowStepExecutionDetailsProps { workflowExecutionId: string; stepExecution?: WorkflowStepExecutionDto; workflowExecutionDuration?: number; + isLoadingStepData?: boolean; } export const WorkflowStepExecutionDetails = React.memo( - ({ workflowExecutionId, stepExecution, workflowExecutionDuration }) => { + ({ workflowExecutionId, stepExecution, workflowExecutionDuration, isLoadingStepData }) => { const isFinished = useMemo( () => Boolean(stepExecution?.status && isTerminalStatus(stepExecution.status)), [stepExecution?.status] @@ -47,10 +48,13 @@ export const WorkflowStepExecutionDetails = React.memo { if (isTriggerPseudoStep) { const pseudoTabs: { id: string; name: string }[] = []; - if (stepExecution?.input) { + if (hasInput) { pseudoTabs.push({ id: 'input', name: 'Input', @@ -61,14 +65,14 @@ export const WorkflowStepExecutionDetails = React.memo(tabs[0].id); @@ -128,68 +132,76 @@ export const WorkflowStepExecutionDetails = React.memo {isFinished ? ( - {selectedTabId === 'output' && ( + {isLoadingStepData ? ( + + + + ) : ( <> - {isTriggerPseudoStep && ( + {selectedTabId === 'output' && ( <> - - {`{{ }}`}, - }} - /> - - + {isTriggerPseudoStep && ( + <> + + {`{{ }}`}, + }} + /> + + + + )} + )} - - - )} - {selectedTabId === 'input' && ( - <> - {isTriggerPseudoStep && ( + {selectedTabId === 'input' && ( <> - - - {triggerType === 'manual' - ? `{{ inputs. }}` - : `{{ event. }}`} - - ), - }} - /> - - + {isTriggerPseudoStep && ( + <> + + + {triggerType === 'manual' + ? `{{ inputs. }}` + : `{{ event. }}`} + + ), + }} + /> + + + + )} + )} - )} diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/execution_context/use_lazy_step_execution_fetcher.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/execution_context/use_lazy_step_execution_fetcher.ts new file mode 100644 index 0000000000000..9cc6f9127fb78 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/execution_context/use_lazy_step_execution_fetcher.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useRef } from 'react'; +import { useQueryClient } from '@kbn/react-query'; +import type { EsWorkflowStepExecution } from '@kbn/workflows'; +import { isTerminalStatus } from '@kbn/workflows'; +import type { WorkflowStepExecutionDto } from '@kbn/workflows/types/v1'; +import type { StepExecutionData } from './build_execution_context'; +import { useKibana } from '../../../../hooks/use_kibana'; + +const STEP_EXECUTION_QUERY_KEY = 'stepExecution'; + +function toStepExecutionData(step: EsWorkflowStepExecution): StepExecutionData { + return { + output: step.output, + error: step.error, + input: step.input, + status: step.status, + state: step.state as StepExecutionData['state'], + }; +} + +/** + * Provides a stable ref to a function that lazily fetches a step execution's + * I/O data. Checks the React Query cache first (shared with useStepExecution + * in the execution detail panel); falls back to an HTTP request and populates + * the cache so both directions stay in sync. + * + * The returned ref is updated every render so it always closes over the latest + * execution ID and step executions, matching the ref-pattern used by the + * Monaco hover provider. + */ +export function useLazyStepExecutionFetcher( + executionId: string | undefined, + stepExecutions: WorkflowStepExecutionDto[] | undefined +) { + const { http } = useKibana().services; + const queryClient = useQueryClient(); + + const executionIdRef = useRef(executionId); + executionIdRef.current = executionId; + + const stepExecutionsRef = useRef(stepExecutions); + stepExecutionsRef.current = stepExecutions; + + const fetchRef = useRef<(stepId: string) => Promise>(async () => null); + + fetchRef.current = async (stepId: string): Promise => { + const currentExecutionId = executionIdRef.current; + if (!currentExecutionId) { + return null; + } + + const stepDocId = stepExecutionsRef.current?.find((s) => s.stepId === stepId)?.id; + if (!stepDocId) { + return null; + } + + const queryKey = [STEP_EXECUTION_QUERY_KEY, currentExecutionId, stepDocId]; + const cached = queryClient.getQueryData(queryKey); + + // Only trust the cache for terminal steps — their data won't change. + // Running steps need a fresh fetch since output may have appeared. + const stepInfo = stepExecutionsRef.current?.find((s) => s.stepId === stepId); + const isStepTerminal = stepInfo?.status && isTerminalStatus(stepInfo.status); + + if (cached && isStepTerminal) { + return toStepExecutionData(cached); + } + + try { + const stepExecution = await http.get( + `/api/workflowExecutions/${currentExecutionId}/steps/${stepDocId}` + ); + if (!stepExecution) { + return null; + } + queryClient.setQueryData(queryKey, stepExecution); + return toStepExecutionData(stepExecution); + } catch { + return null; + } + }; + + return fetchRef; +} diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/provider_interfaces.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/provider_interfaces.ts index 87029594f0260..a6e0c04e1ff3d 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/provider_interfaces.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/provider_interfaces.ts @@ -12,7 +12,10 @@ import type YAML from 'yaml'; import type { Scalar, YAMLMap } from 'yaml'; import type { monaco } from '@kbn/monaco'; -import type { ExecutionContext } from '../execution_context/build_execution_context'; +import type { + ExecutionContext, + StepExecutionData, +} from '../execution_context/build_execution_context'; /** * Context information for hover providers @@ -143,6 +146,8 @@ export interface ProviderConfig { getYamlDocument: () => YAML.Document | null; /** Function to get the current execution context (for template expression hover) */ getExecutionContext?: () => ExecutionContext | null; + /** Lazily fetch a step's I/O data and merge into the execution context */ + fetchStepExecutionData?: (stepId: string) => Promise; /** Additional configuration options */ options?: Record; } diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.test.ts new file mode 100644 index 0000000000000..fc8e55c45f63b --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.test.ts @@ -0,0 +1,223 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { monaco } from '@kbn/monaco'; +import type { ProviderConfig } from './provider_interfaces'; +import { UnifiedHoverProvider } from './unified_hover_provider'; +import type { + ExecutionContext, + StepExecutionData, +} from '../execution_context/build_execution_context'; + +jest.mock('../template_expression/parse_template_at_position'); +jest.mock('../template_expression/evaluate_expression'); +jest.mock('../hover/get_intercepted_hover', () => ({ + getInterceptedHover: jest.fn().mockResolvedValue(null), +})); + +const { parseTemplateAtPosition } = jest.requireMock( + '../template_expression/parse_template_at_position' +); +const { evaluateExpression } = jest.requireMock('../template_expression/evaluate_expression'); + +const createMockModel = () => + ({ + uri: { toString: () => 'inmemory://test' }, + getLineContent: jest.fn().mockReturnValue(' message: "{{ steps.search.output.hits }}"'), + getLineDecorations: jest.fn().mockReturnValue([]), + } as unknown as monaco.editor.ITextModel); + +const createMockPosition = (line = 1, column = 25) => new monaco.Position(line, column); + +describe('UnifiedHoverProvider - lazy-loading step I/O', () => { + let fetchStepExecutionData: jest.Mock; + let getExecutionContext: jest.Mock; + let provider: UnifiedHoverProvider; + + const stepOutputValue = { hits: [{ _id: '1', title: 'result' }] }; + + const baseExecutionContext: ExecutionContext = { + steps: { + search: { + status: 'completed', + }, + }, + }; + + const enrichedStepData: StepExecutionData = { + status: 'completed', + output: stepOutputValue, + input: { query: '*' }, + }; + + const templateInfo = { + isInsideTemplate: true, + expression: 'steps.search.output.hits', + variablePath: 'steps.search.output.hits', + pathSegments: ['steps', 'search', 'output', 'hits'], + cursorSegmentIndex: 3, + pathUpToCursor: ['steps', 'search', 'output', 'hits'], + filters: [], + isOnFilter: false, + templateRange: new monaco.Range(1, 20, 1, 42), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + fetchStepExecutionData = jest.fn().mockResolvedValue(enrichedStepData); + getExecutionContext = jest.fn().mockReturnValue(baseExecutionContext); + + // parseTemplateAtPosition returns our template info for every call + parseTemplateAtPosition.mockReturnValue(templateInfo); + // evaluateExpression resolves the value from the (enriched) context + evaluateExpression.mockImplementation( + ({ expression, context }: { expression: string; context: ExecutionContext }) => { + if (expression === 'steps.search.output.hits' && context.steps.search?.output) { + return (context.steps.search.output as Record).hits; + } + return undefined; + } + ); + + // Suppress validation markers and line decorations + (monaco.editor.getModelMarkers as jest.Mock) = jest.fn().mockReturnValue([]); + + const config: ProviderConfig = { + getYamlDocument: () => null, + getExecutionContext, + fetchStepExecutionData, + options: { http: {} as any, notifications: {} as any }, + }; + provider = new UnifiedHoverProvider(config); + }); + + it('should fetch step data and return enriched hover on first hover', async () => { + const result = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + expect(fetchStepExecutionData).toHaveBeenCalledWith('search'); + expect(result).not.toBeNull(); + expect(result!.contents[0]).toEqual( + expect.objectContaining({ + value: expect.stringContaining('steps.search.output.hits'), + }) + ); + // Should contain the resolved value, not "undefined" + expect(result!.contents[0]).toEqual( + expect.objectContaining({ + value: expect.not.stringContaining('undefined'), + }) + ); + }); + + it('should return enriched hover on second hover (cache hit, no duplicate fetch)', async () => { + // First hover + const result1 = await provider.provideCustomHover(createMockModel(), createMockPosition()); + expect(fetchStepExecutionData).toHaveBeenCalledTimes(1); + expect(result1).not.toBeNull(); + + // Second hover — same step, same execution context (context ref unchanged, no I/O in it) + const result2 = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + // fetchStepExecutionData is called again (cache dedup is in the caller, not the provider) + expect(fetchStepExecutionData).toHaveBeenCalledTimes(2); + expect(result2).not.toBeNull(); + // Value should still resolve correctly — this is the regression scenario + expect(result2!.contents[0]).toEqual( + expect.objectContaining({ + value: expect.not.stringContaining('undefined'), + }) + ); + }); + + it('should not call fetchStepExecutionData when step output is already in context', async () => { + const contextWithOutput: ExecutionContext = { + steps: { + search: { + status: 'completed', + output: stepOutputValue, + }, + }, + }; + getExecutionContext.mockReturnValue(contextWithOutput); + + const result = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + expect(fetchStepExecutionData).not.toHaveBeenCalled(); + expect(result).not.toBeNull(); + }); + + it('should handle fetchStepExecutionData returning null gracefully', async () => { + fetchStepExecutionData.mockResolvedValue(null); + + const result = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + expect(result).not.toBeNull(); + // Value should be "undefined in the current execution context" + expect(result!.contents[0]).toEqual( + expect.objectContaining({ + value: expect.stringContaining('undefined'), + }) + ); + }); + + it('should work without fetchStepExecutionData configured', async () => { + const config: ProviderConfig = { + getYamlDocument: () => null, + getExecutionContext, + options: { http: {} as any, notifications: {} as any }, + }; + const providerWithoutFetch = new UnifiedHoverProvider(config); + + const result = await providerWithoutFetch.provideCustomHover( + createMockModel(), + createMockPosition() + ); + + expect(result).not.toBeNull(); + // No fetcher → no enrichment → value is undefined + expect(result!.contents[0]).toEqual( + expect.objectContaining({ + value: expect.stringContaining('undefined'), + }) + ); + }); + + it('should return null when execution context is not available', async () => { + getExecutionContext.mockReturnValue(null); + + const result = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + expect(result).toBeNull(); + expect(fetchStepExecutionData).not.toHaveBeenCalled(); + }); + + it('should resolve non-step expressions without calling fetchStepExecutionData', async () => { + const contextWithInputs: ExecutionContext = { + inputs: { name: 'test' }, + steps: {}, + }; + getExecutionContext.mockReturnValue(contextWithInputs); + + const inputTemplateInfo = { + ...templateInfo, + expression: 'inputs.name', + variablePath: 'inputs.name', + pathSegments: ['inputs', 'name'], + pathUpToCursor: ['inputs', 'name'], + }; + parseTemplateAtPosition.mockReturnValue(inputTemplateInfo); + evaluateExpression.mockReturnValue('test'); + + const result = await provider.provideCustomHover(createMockModel(), createMockPosition()); + + expect(fetchStepExecutionData).not.toHaveBeenCalled(); + expect(result).not.toBeNull(); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.ts index 3f846fb6c2ab0..545a1de2492f4 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_providers/unified_hover_provider.ts @@ -20,7 +20,10 @@ import { getMonacoConnectorHandler } from './provider_registry'; import { getPathAtOffset } from '../../../../../common/lib/yaml'; import { performComputation } from '../../../../entities/workflows/store/workflow_detail/utils/computation'; import { isYamlValidationMarkerOwner } from '../../../../features/validate_workflow_yaml/model/types'; -import type { ExecutionContext } from '../execution_context/build_execution_context'; +import type { + ExecutionContext, + StepExecutionData, +} from '../execution_context/build_execution_context'; import { getInterceptedHover } from '../hover/get_intercepted_hover'; import { evaluateExpression } from '../template_expression/evaluate_expression'; import { parseTemplateAtPosition } from '../template_expression/parse_template_at_position'; @@ -37,10 +40,12 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { private readonly getYamlDocument: () => YAML.Document | null; private readonly getExecutionContext?: () => ExecutionContext | null; + private readonly fetchStepExecutionData?: (stepId: string) => Promise; constructor(config: ProviderConfig) { this.getYamlDocument = config.getYamlDocument; this.getExecutionContext = config.getExecutionContext; + this.fetchStepExecutionData = config.fetchStepExecutionData; } async provideHover( @@ -62,9 +67,15 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { position: monaco.Position ): Promise { try { - // console.log('UnifiedHoverProvider: provideHover called at position', position); + // FIRST: Check if cursor is inside a template expression {{ }} + // Template expression hover (runtime values) takes priority over validation + // decorations and markers, so we check this before anything else. + const templateInfo = parseTemplateAtPosition(model, position); + if (templateInfo && templateInfo.isInsideTemplate) { + return await this.handleTemplateExpressionHover(model, position, templateInfo); + } - // FIRST: Check if there are validation errors at this position OR nearby + // Check if there are validation errors at this position OR nearby // If there are, let the validation-only hover provider handle it const markers = monaco.editor.getModelMarkers({ resource: model.uri }); const validationMarkersNearby = markers.filter( @@ -78,17 +89,10 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { ); if (validationMarkersNearby.length > 0) { - // console.log('UnifiedHoverProvider: Found validation errors nearby, skipping to let validation provider handle'); - // console.log('Nearby validation markers:', validationMarkersNearby.map(m => ({ - // message: m.message, - // startCol: m.startColumn, - // endCol: m.endColumn, - // currentCol: position.column - // }))); return null; } - // Second: check for decorations at this position, e.g. we don't want to show generic hover content over variables (valid or invalid) + // Check for decorations at this position, e.g. we don't want to show generic hover content over variables (valid or invalid) const decorations = model .getLineDecorations(position.lineNumber) .filter((decoration) => decoration.options.hoverMessage); @@ -96,34 +100,18 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { return null; } - // Third: Check if cursor is inside a template expression {{ }} - const templateInfo = parseTemplateAtPosition(model, position); - if (templateInfo && templateInfo.isInsideTemplate) { - // Handle template expression hover (only if execution context is available) - return this.handleTemplateExpressionHover(model, position, templateInfo); - } - // Get YAML document const yamlDocument = this.getYamlDocument(); if (!yamlDocument) { - // console.log('UnifiedHoverProvider: No YAML document available'); return null; } // Detect context at current position const context = await this.buildHoverContext(model, position, yamlDocument); if (!context) { - // console.log('UnifiedHoverProvider: Could not build hover context'); return null; } - // console.log('✅ UnifiedHoverProvider: Context detected', { - // connectorType: context.connectorType, - // yamlPath: context.yamlPath, - // stepContext: context.stepContext, - // parameterContext: context.parameterContext, - // }); - // Only show connector hover for specific fields (type, or connector parameters) // Don't show hover for arbitrary string values in the YAML if (!this.shouldShowConnectorHover(context)) { @@ -133,37 +121,21 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { // Find appropriate Monaco handler const handler = getMonacoConnectorHandler(context.connectorType); if (!handler) { - /* - console.log( - 'UnifiedHoverProvider: No Monaco handler found for connector type:', - context.connectorType - ); - */ return null; } - /* - console.log( - 'UnifiedHoverProvider: Found Monaco handler for connector type:', - context.connectorType - ); - */ - // Generate hover content const hoverContent = await handler.generateHoverContent(context); if (!hoverContent) { - // console.log('UnifiedHoverProvider: Handler returned no hover content'); return null; } - // console.log('UnifiedHoverProvider: Returning hover content'); // Don't return a range for connector hovers - this prevents Monaco from highlighting // Only template expression hovers should have ranges to show the blue highlight return { contents: [hoverContent], }; } catch (error) { - // console.warn('UnifiedHoverProvider: Error providing hover', error); return null; } } @@ -184,13 +156,11 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { // If no path found (e.g., cursor after colon), try to find it from the current line if (yamlPath.length === 0) { yamlPath = this.getPathFromCurrentLine(model, position, yamlDocument); - // console.log('🔍 buildHoverContext: Found path from current line:', yamlPath); } // Detect connector type and step context const stepContext = this.detectStepContext(model.getValue(), position); if (!stepContext?.stepType) { - // console.log('🔍 buildHoverContext: No stepContext found for path:', yamlPath); return null; } @@ -211,7 +181,6 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { parameterContext, }; } catch (error) { - // console.warn('UnifiedHoverProvider: Error building context', error); return null; } } @@ -228,17 +197,10 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { const lineContent = model.getLineContent(position.lineNumber); const beforeCursor = lineContent.substring(0, position.column - 1); - // console.log('🔍 getPathFromCurrentLine (hover):', { - // lineContent: JSON.stringify(lineContent), - // beforeCursor: JSON.stringify(beforeCursor), - // position: { line: position.lineNumber, column: position.column }, - // }); - // Check if we're after a colon (common case: "with:|") const colonMatch = beforeCursor.match(/(\w+)\s*:\s*$/); if (colonMatch) { const keyName = colonMatch[1]; - // console.log('🔍 Found key after colon:', keyName); // Try to find this key in the document by looking at nearby positions // Look at the start of the key on this line @@ -269,14 +231,12 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { const testPosition = lineStartPosition + offset; const testPath = getPathAtOffset(yamlDocument, testPosition); if (testPath.length > 0) { - // console.log('🔍 Found fallback path at offset', offset, ':', testPath); return testPath; } } return []; } catch (error) { - // console.warn('UnifiedHoverProvider: Error getting path from current line', error); return []; } } @@ -293,7 +253,6 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { if (!stepInfo) { return null; } - // console.log('🔍 detectStepContext: Step info:', stepInfo); return { stepName: stepInfo.stepId, stepType: stepInfo.stepType, @@ -354,14 +313,53 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { return false; } + /** + * Extract the step ID from a template expression path (e.g., "steps.search.output.hits" -> "search") + */ + private extractStepIdFromPath(pathSegments: string[]): string | null { + if (pathSegments.length >= 2 && pathSegments[0] === 'steps') { + return pathSegments[1]; + } + return null; + } + + /** + * Fetch step I/O data on demand if not already available. + * Returns the enriched step data (merged with fetched I/O), or the existing + * data if already present. Returns null if the step doesn't exist. + * Deduplication is handled by the React Query cache inside fetchStepExecutionData. + */ + private async fetchStepDataIfNeeded( + stepData: StepExecutionData | undefined, + stepId: string + ): Promise { + if (!stepData) { + return null; + } + + if (!this.fetchStepExecutionData) { + return stepData; + } + + if (stepData.output !== undefined) { + return stepData; + } + + const fullStepData = await this.fetchStepExecutionData(stepId); + if (fullStepData) { + return { ...stepData, ...fullStepData }; + } + return stepData; + } + /** * Handle hover for template expressions {{ }} */ - private handleTemplateExpressionHover( + private async handleTemplateExpressionHover( model: monaco.editor.ITextModel, position: monaco.Position, templateInfo: ReturnType - ): monaco.languages.Hover | null { + ): Promise { if (!templateInfo || !this.getExecutionContext) { return null; } @@ -372,23 +370,37 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider { } try { + // Build a local context, enriching with lazily-fetched step I/O if needed + let evalContext: ExecutionContext = executionContext; + const stepId = this.extractStepIdFromPath(templateInfo.pathSegments); + if (stepId) { + const enrichedStep = await this.fetchStepDataIfNeeded( + executionContext.steps[stepId], + stepId + ); + if (enrichedStep && enrichedStep !== executionContext.steps[stepId]) { + evalContext = { + ...executionContext, + steps: { ...executionContext.steps, [stepId]: enrichedStep }, + }; + } + } + // Determine what to evaluate let value: JsonValue | undefined; let evaluatedPath: string; if (templateInfo.filters.length > 0 && templateInfo.isOnFilter) { - // Cursor is on the filter part - evaluate with filters evaluatedPath = templateInfo.expression; value = evaluateExpression({ expression: templateInfo.expression, - context: executionContext, + context: evalContext, }); } else { - // Cursor is on the variable path (not filter) - resolve path only evaluatedPath = templateInfo.pathUpToCursor.join('.'); value = evaluateExpression({ expression: evaluatedPath, - context: executionContext, + context: evalContext, }); } diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx index 859865df19d6d..cb9dd505c340f 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx @@ -13,6 +13,7 @@ import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { I18nProviderMock } from '@kbn/core-i18n-browser-mocks/src/i18n_context_mock'; import { monaco, YAML_LANG_ID } from '@kbn/monaco'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import type { WorkflowYAMLEditorProps } from './workflow_yaml_editor'; import { WorkflowYAMLEditor } from './workflow_yaml_editor'; import { useSaveYaml } from '../../../entities/workflows/model/use_save_yaml'; @@ -221,17 +222,21 @@ describe('WorkflowYAMLEditor', () => { editorRef: { current: null }, }; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const renderWithProviders = ( component: React.ReactElement, store?: ReturnType ) => { const testStore = store || createMockStore(); return render( - - - {component} - - + + + + {component} + + + ); }; @@ -254,13 +259,15 @@ describe('WorkflowYAMLEditor', () => { it('updates store when editor content changes', async () => { const store = createMockStore(); const { container } = render( - - - - - - - + + + + + + + + + ); const textarea = container.querySelector( diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx index b63787a3646e9..eb575a651fa57 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx @@ -19,7 +19,6 @@ import type YAML from 'yaml'; import { FormattedMessage } from '@kbn/i18n-react'; import { monaco, YAML_LANG_ID } from '@kbn/monaco'; import { isTriggerType } from '@kbn/workflows'; -import type { WorkflowStepExecutionDto } from '@kbn/workflows/types/v1'; import type { z } from '@kbn/zod/v4'; import { ActionsMenuButton } from './actions_menu_button'; import { @@ -67,8 +66,11 @@ import { useKibana } from '../../../hooks/use_kibana'; import { UnsavedChangesPrompt, YamlEditor } from '../../../shared/ui'; import { triggerSchemas } from '../../../trigger_schemas'; import { interceptMonacoYamlProvider } from '../lib/autocomplete/intercept_monaco_yaml_provider'; -import { buildExecutionContext } from '../lib/execution_context/build_execution_context'; -import type { ExecutionContext } from '../lib/execution_context/build_execution_context'; +import { + buildExecutionContext, + type ExecutionContext, +} from '../lib/execution_context/build_execution_context'; +import { useLazyStepExecutionFetcher } from '../lib/execution_context/use_lazy_step_execution_fetcher'; import { interceptMonacoYamlHoverProvider } from '../lib/hover/intercept_monaco_yaml_hover_provider'; import { ElasticsearchMonacoConnectorHandler, @@ -181,13 +183,12 @@ export const WorkflowYAMLEditor = ({ const editorRef = useRef(null); const stepExecutions = useSelector(selectStepExecutions); - const stepExecutionsRef = useRef(stepExecutions); - stepExecutionsRef.current = stepExecutions; const execution = useSelector(selectExecution); const executionContextRef = useRef(null); // Build execution context when step executions are available + // Steps will have status/error/state but no I/O - those are lazy-loaded on hover useEffect(() => { if (isExecutionYaml && stepExecutions) { executionContextRef.current = buildExecutionContext(stepExecutions, execution?.context); @@ -196,6 +197,8 @@ export const WorkflowYAMLEditor = ({ } }, [isExecutionYaml, stepExecutions, execution?.context]); + const fetchStepExecutionDataRef = useLazyStepExecutionFetcher(execution?.id, stepExecutions); + // Ref to track saving state for keyboard handlers const isSavingRef = useRef(false); isSavingRef.current = isSaving; @@ -370,6 +373,7 @@ export const WorkflowYAMLEditor = ({ const providerConfig = { getYamlDocument: () => yamlDocumentRef.current || null, getExecutionContext: () => executionContextRef.current, + fetchStepExecutionData: (stepId: string) => fetchStepExecutionDataRef.current(stepId), options: { http, notifications, diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.test.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.test.ts new file mode 100644 index 0000000000000..ae2aac7658dbe --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.test.ts @@ -0,0 +1,248 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ElasticsearchClient } from '@kbn/core/server'; +import { loggerMock } from '@kbn/logging-mocks'; +import { getWorkflowExecution } from './get_workflow_execution'; + +jest.mock('./search_step_executions', () => ({ + searchStepExecutions: jest.fn().mockResolvedValue([]), +})); + +const { searchStepExecutions } = jest.requireMock('./search_step_executions'); + +describe('getWorkflowExecution', () => { + let mockEsClient: jest.Mocked; + let mockLogger: ReturnType; + + const baseParams = { + workflowExecutionIndex: '.workflows-executions', + stepsExecutionIndex: '.workflows-steps', + workflowExecutionId: 'exec-1', + spaceId: 'default', + }; + + const baseExecutionDoc = { + spaceId: 'default', + workflowId: 'workflow-1', + status: 'completed', + startedAt: '2024-01-01T00:00:00Z', + stepExecutionIds: ['step-doc-1', 'step-doc-2'], + workflowDefinition: { version: '1', name: 'test', enabled: true, triggers: [], steps: [] }, + }; + + beforeEach(() => { + mockEsClient = { + get: jest.fn(), + mget: jest.fn(), + search: jest.fn(), + } as any; + mockLogger = loggerMock.create(); + jest.clearAllMocks(); + }); + + describe('source excludes with mget (stepExecutionIds present)', () => { + beforeEach(() => { + mockEsClient.get.mockResolvedValue({ + _source: baseExecutionDoc, + } as any); + mockEsClient.mget.mockResolvedValue({ + docs: [ + { found: true, _source: { stepId: 's1', status: 'completed', globalExecutionIndex: 0 } }, + { found: true, _source: { stepId: 's2', status: 'completed', globalExecutionIndex: 1 } }, + ], + } as any); + }); + + it('should not pass _source_excludes when both includeInput and includeOutput are true', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: true, + includeOutput: true, + }); + + expect(mockEsClient.mget).toHaveBeenCalledWith( + expect.not.objectContaining({ _source_excludes: expect.anything() }) + ); + }); + + it('should pass _source_excludes: ["input", "output"] when both are false', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: false, + includeOutput: false, + }); + + expect(mockEsClient.mget).toHaveBeenCalledWith( + expect.objectContaining({ + _source_excludes: ['input', 'output'], + }) + ); + }); + + it('should pass _source_excludes: ["input"] when only includeInput is false', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: false, + includeOutput: true, + }); + + expect(mockEsClient.mget).toHaveBeenCalledWith( + expect.objectContaining({ + _source_excludes: ['input'], + }) + ); + }); + + it('should pass _source_excludes: ["output"] when only includeOutput is false', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: true, + includeOutput: false, + }); + + expect(mockEsClient.mget).toHaveBeenCalledWith( + expect.objectContaining({ + _source_excludes: ['output'], + }) + ); + }); + + it('should default includeInput and includeOutput to false when omitted', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + }); + + expect(mockEsClient.mget).toHaveBeenCalledWith( + expect.objectContaining({ + _source_excludes: ['input', 'output'], + }) + ); + }); + }); + + describe('source excludes with search fallback (no stepExecutionIds)', () => { + beforeEach(() => { + mockEsClient.get.mockResolvedValue({ + _source: { ...baseExecutionDoc, stepExecutionIds: undefined }, + } as any); + searchStepExecutions.mockResolvedValue([]); + }); + + it('should pass sourceExcludes to searchStepExecutions when includeInput/includeOutput are false', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: false, + includeOutput: false, + }); + + expect(searchStepExecutions).toHaveBeenCalledWith( + expect.objectContaining({ + sourceExcludes: ['input', 'output'], + }) + ); + }); + + it('should pass empty sourceExcludes when both flags are true', async () => { + await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + includeInput: true, + includeOutput: true, + }); + + expect(searchStepExecutions).toHaveBeenCalledWith( + expect.objectContaining({ + sourceExcludes: [], + }) + ); + }); + }); + + describe('basic behavior', () => { + it('should return null when document is not found (404)', async () => { + const notFoundError = new Error('Not found'); + Object.assign(notFoundError, { meta: { statusCode: 404 } }); + mockEsClient.get.mockRejectedValue(notFoundError); + + const result = await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + }); + + expect(result).toBeNull(); + }); + + it('should return null when spaceId does not match', async () => { + mockEsClient.get.mockResolvedValue({ + _source: { ...baseExecutionDoc, spaceId: 'other-space' }, + } as any); + + const result = await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + }); + + expect(result).toBeNull(); + }); + + it('should return the execution DTO with step executions', async () => { + mockEsClient.get.mockResolvedValue({ + _source: baseExecutionDoc, + } as any); + mockEsClient.mget.mockResolvedValue({ + docs: [ + { + found: true, + _source: { + stepId: 's1', + status: 'completed', + globalExecutionIndex: 1, + output: { result: 'ok' }, + }, + }, + { + found: true, + _source: { + stepId: 's2', + status: 'completed', + globalExecutionIndex: 0, + input: { arg: 1 }, + }, + }, + ], + } as any); + + const result = await getWorkflowExecution({ + ...baseParams, + esClient: mockEsClient, + logger: mockLogger, + }); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('exec-1'); + expect(result?.stepExecutions).toHaveLength(2); + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts index 6b1f860df3856..76a24a7b627db 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts @@ -23,7 +23,8 @@ import { stringifyWorkflowDefinition } from '../../../common/lib/yaml'; async function getStepExecutionsByIds( esClient: ElasticsearchClient, stepsExecutionIndex: string, - stepExecutionIds: string[] + stepExecutionIds: string[], + sourceExcludes?: string[] ): Promise { if (stepExecutionIds.length === 0) { return []; @@ -32,6 +33,7 @@ async function getStepExecutionsByIds( const mgetResponse = await esClient.mget({ index: stepsExecutionIndex, ids: stepExecutionIds, + ...(sourceExcludes?.length ? { _source_excludes: sourceExcludes } : {}), }); const steps: EsWorkflowStepExecution[] = []; @@ -50,6 +52,8 @@ interface GetWorkflowExecutionParams { stepsExecutionIndex: string; workflowExecutionId: string; spaceId: string; + includeInput?: boolean; + includeOutput?: boolean; } export const getWorkflowExecution = async ({ @@ -59,6 +63,8 @@ export const getWorkflowExecution = async ({ stepsExecutionIndex, workflowExecutionId, spaceId, + includeInput = false, + includeOutput = false, }: GetWorkflowExecutionParams): Promise => { try { // Use direct GET by _id for O(1) lookup performance instead of search @@ -90,13 +96,18 @@ export const getWorkflowExecution = async ({ let stepExecutions: EsWorkflowStepExecution[]; + const sourceExcludes: string[] = []; + if (!includeInput) sourceExcludes.push('input'); + if (!includeOutput) sourceExcludes.push('output'); + // Use mget if we have step execution IDs - this is O(1) and real-time // (reads from translog, no refresh needed) if (doc.stepExecutionIds && doc.stepExecutionIds.length > 0) { stepExecutions = await getStepExecutionsByIds( esClient, stepsExecutionIndex, - doc.stepExecutionIds + doc.stepExecutionIds, + sourceExcludes ); } else { // Fallback to search for backward compatibility (old workflows without stepExecutionIds) @@ -106,6 +117,7 @@ export const getWorkflowExecution = async ({ stepsExecutionIndex, workflowExecutionId, spaceId, + sourceExcludes, }); } diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_step_executions.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_step_executions.ts index 4747b02749a45..5c121128bd51b 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_step_executions.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_step_executions.ts @@ -18,6 +18,7 @@ interface SearchStepExecutionsParams { workflowExecutionId: string; additionalQuery?: estypes.QueryDslQueryContainer; spaceId: string; + sourceExcludes?: string[]; } export const searchStepExecutions = async ({ @@ -27,6 +28,7 @@ export const searchStepExecutions = async ({ workflowExecutionId, additionalQuery, spaceId, + sourceExcludes, }: SearchStepExecutionsParams): Promise => { try { logger.debug(`Searching workflows in index ${stepsExecutionIndex}`); @@ -47,6 +49,7 @@ export const searchStepExecutions = async ({ must: mustQueries, }, }, + ...(sourceExcludes?.length ? { _source: { excludes: sourceExcludes } } : {}), sort: 'startedAt:desc', from: 0, size: 1000, // TODO: without it, it returns up to 10 results by default. We should improve this. diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.test.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.test.ts index 19b8f4b6eb174..e358b21e8f78c 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.test.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.test.ts @@ -96,6 +96,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/api/workflowExecutions/execution-123' }, }; @@ -103,7 +104,10 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { await routeHandler(mockContext, mockRequest, mockResponse); - expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith('execution-123', 'default'); + expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith('execution-123', 'default', { + includeInput: true, + includeOutput: true, + }); expect(mockResponse.ok).toHaveBeenCalledWith({ body: mockExecution }); }); @@ -113,6 +117,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'non-existent-execution' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/api/workflowExecutions/non-existent-execution' }, }; @@ -122,7 +127,8 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith( 'non-existent-execution', - 'default' + 'default', + { includeInput: true, includeOutput: true } ); expect(mockResponse.notFound).toHaveBeenCalledWith(); }); @@ -134,6 +140,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/api/workflowExecutions/execution-123' }, }; @@ -168,6 +175,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'execution-456' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/s/custom-space/api/workflowExecutions/execution-456' }, }; @@ -177,7 +185,8 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith( 'execution-456', - 'custom-space' + 'custom-space', + { includeInput: true, includeOutput: true } ); expect(mockResponse.ok).toHaveBeenCalledWith({ body: mockExecution }); }); @@ -191,6 +200,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/api/workflowExecutions/execution-123' }, }; @@ -213,6 +223,7 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { const mockContext = {}; const mockRequest = { params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: true, includeOutput: true }, headers: {}, url: { pathname: '/api/workflowExecutions/execution-123' }, }; @@ -227,5 +238,52 @@ describe('GET /api/workflowExecutions/{workflowExecutionId}', () => { }, }); }); + + describe('includeInput / includeOutput query params', () => { + const mockExecution = { + id: 'execution-123', + status: 'completed', + steps: [], + }; + + it('should forward includeInput=false and includeOutput=false to the API', async () => { + workflowsApi.getWorkflowExecution = jest.fn().mockResolvedValue(mockExecution); + + const mockRequest = { + params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: false, includeOutput: false }, + headers: {}, + url: { pathname: '/api/workflowExecutions/execution-123' }, + }; + const mockResponse = createMockResponse(); + + await routeHandler({}, mockRequest, mockResponse); + + expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith('execution-123', 'default', { + includeInput: false, + includeOutput: false, + }); + expect(mockResponse.ok).toHaveBeenCalledWith({ body: mockExecution }); + }); + + it('should forward mixed includeInput=true and includeOutput=false to the API', async () => { + workflowsApi.getWorkflowExecution = jest.fn().mockResolvedValue(mockExecution); + + const mockRequest = { + params: { workflowExecutionId: 'execution-123' }, + query: { includeInput: true, includeOutput: false }, + headers: {}, + url: { pathname: '/api/workflowExecutions/execution-123' }, + }; + const mockResponse = createMockResponse(); + + await routeHandler({}, mockRequest, mockResponse); + + expect(workflowsApi.getWorkflowExecution).toHaveBeenCalledWith('execution-123', 'default', { + includeInput: true, + includeOutput: false, + }); + }); + }); }); }); diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.ts index d8408ece1ac56..8eeb382bacc3b 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_execution_by_id.ts @@ -29,13 +29,21 @@ export function registerGetWorkflowExecutionByIdRoute({ params: schema.object({ workflowExecutionId: schema.string(), }), + query: schema.object({ + includeInput: schema.boolean({ defaultValue: false }), + includeOutput: schema.boolean({ defaultValue: false }), + }), }, }, withLicenseCheck(async (context, request, response) => { try { const { workflowExecutionId } = request.params; + const { includeInput, includeOutput } = request.query; const spaceId = spaces.getSpaceId(request); - const workflowExecution = await api.getWorkflowExecution(workflowExecutionId, spaceId); + const workflowExecution = await api.getWorkflowExecution(workflowExecutionId, spaceId, { + includeInput, + includeOutput, + }); if (!workflowExecution) { return response.notFound(); } diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts index 0e1725ad7cd77..b5ad095416e17 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts @@ -359,9 +359,10 @@ export class WorkflowsManagementApi { public async getWorkflowExecution( workflowExecutionId: string, - spaceId: string + spaceId: string, + options?: { includeInput?: boolean; includeOutput?: boolean } ): Promise { - return this.workflowsService.getWorkflowExecution(workflowExecutionId, spaceId); + return this.workflowsService.getWorkflowExecution(workflowExecutionId, spaceId, options); } public async getWorkflowExecutionLogs(params: { diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts index 7f794fd827fb0..f273ffc7f2c0b 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts @@ -2163,7 +2163,7 @@ steps: }); describe('getWorkflowExecution', () => { - it('should return workflow execution with steps', async () => { + it('should return workflow execution with steps, excluding I/O by default', async () => { // Mock the get call for execution (using direct GET by ID) const mockExecutionGetResponse = { _id: 'execution-1', @@ -2214,7 +2214,7 @@ steps: id: 'execution-1', }); - // Verify the step executions search call + // Verify the step executions search call (includeInput/includeOutput default to false) expect(mockEsClient.search).toHaveBeenCalledWith({ index: WORKFLOWS_STEP_EXECUTIONS_INDEX, query: { @@ -2222,6 +2222,7 @@ steps: must: [{ match: { workflowRunId: 'execution-1' } }, { term: { spaceId: 'default' } }], }, }, + _source: { excludes: ['input', 'output'] }, sort: 'startedAt:desc', from: 0, size: 1000, diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts index d7e988092b25b..f4b86cd9d9df6 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts @@ -1004,7 +1004,8 @@ export class WorkflowsService { // Helper methods remain the same as they don't interact with SavedObjects public async getWorkflowExecution( executionId: string, - spaceId: string + spaceId: string, + options?: { includeInput?: boolean; includeOutput?: boolean } ): Promise { return getWorkflowExecution({ esClient: this.esClient, @@ -1013,6 +1014,8 @@ export class WorkflowsService { stepsExecutionIndex: WORKFLOWS_STEP_EXECUTIONS_INDEX, workflowExecutionId: executionId, spaceId, + includeInput: options?.includeInput, + includeOutput: options?.includeOutput, }); }