diff --git a/src/models/maestro/process-instances.constants.ts b/src/models/maestro/process-instances.constants.ts index 00a2c6b6e..7f3fc2651 100644 --- a/src/models/maestro/process-instances.constants.ts +++ b/src/models/maestro/process-instances.constants.ts @@ -9,9 +9,3 @@ export const ProcessInstanceMap: { [key: string]: string } = { updatedAt: 'updatedTime' }; -/** - * Maps fields for Process Instance Execution History to ensure consistent naming - */ -export const ProcessInstanceExecutionHistoryMap: { [key: string]: string } = { - startTime: 'startedTime' -}; diff --git a/src/models/maestro/process-instances.internal-types.ts b/src/models/maestro/process-instances.internal-types.ts index 388754273..11a215d4a 100644 --- a/src/models/maestro/process-instances.internal-types.ts +++ b/src/models/maestro/process-instances.internal-types.ts @@ -13,4 +13,59 @@ export interface BpmnVariableMetadata { type: string; elementId: string; source: string; +} + +/** + * Element run from the element-executions API response + * @internal + */ +export interface ElementExecutionRun { + status: string; + startedTimeUtc: string; + completedTimeUtc: string | null; + elementRunId: string; + workflowId: string | null; +} + +/** + * Element execution from the element-executions API response + * @internal + */ +export interface ElementExecution { + completedTimeUtc: string | null; + elementId: string; + elementType: string; + elementExtensionType: string | null; + parentRunId: string | null; + parentElementId: string | null; + parentElementRunId: string | null; + runId: string; + startedTimeUtc: string; + status: string; + elementRuns: ElementExecutionRun[]; +} + +/** + * Top-level response from the element-executions API + * @internal + */ +export interface ElementExecutionsApiResponse { + instanceId: string; + elementExecutions: ElementExecution[]; +} + +/** + * Trace span from the LLMOps Traces/spans API + * @internal + */ +export interface TraceSpan { + Id: string; + TraceId: string; + ParentId: string | null; + Name: string; + StartTime: string; + EndTime: string | null; + Attributes: string | null; + ExpiryTimeUtc: string | null; + UpdatedAt: string; } \ No newline at end of file diff --git a/src/models/maestro/process-instances.models.ts b/src/models/maestro/process-instances.models.ts index 0a5e5c254..a621d2e87 100644 --- a/src/models/maestro/process-instances.models.ts +++ b/src/models/maestro/process-instances.models.ts @@ -96,13 +96,15 @@ export interface ProcessInstancesServiceModel { /** * Get execution history (spans) for a process instance * @param instanceId The ID of the instance to get history for + * @param folderKey The folder key for authorization * @returns Promise resolving to execution history * {@link ProcessInstanceExecutionHistoryResponse} * @example * ```typescript * // Get execution history for a process instance * const history = await processInstances.getExecutionHistory( - * + * , + * * ); * * // Analyze execution timeline @@ -113,7 +115,7 @@ export interface ProcessInstancesServiceModel { * }); * ``` */ - getExecutionHistory(instanceId: string): Promise; + getExecutionHistory(instanceId: string, folderKey: string): Promise; /** * Get BPMN XML file for a process instance @@ -355,8 +357,9 @@ function createProcessInstanceMethods(instanceData: RawProcessInstanceGetRespons async getExecutionHistory(): Promise { if (!instanceData.instanceId) throw new Error('Process instance ID is undefined'); + if (!instanceData.folderKey) throw new Error('Process instance folder key is undefined'); - return service.getExecutionHistory(instanceData.instanceId); + return service.getExecutionHistory(instanceData.instanceId, instanceData.folderKey); }, async getBpmn(): Promise { diff --git a/src/models/maestro/process-instances.types.ts b/src/models/maestro/process-instances.types.ts index a5da8a439..3a328cda1 100644 --- a/src/models/maestro/process-instances.types.ts +++ b/src/models/maestro/process-instances.types.ts @@ -69,7 +69,7 @@ export interface ProcessInstanceExecutionHistoryResponse { startedTime: string; endTime: string | null; attributes: string | null; - createdTime: string; + createdTime?: string; updatedTime?: string; expiredTime: string | null; // TO Do: Add status and attributes interface diff --git a/src/services/maestro/processes/process-instances.ts b/src/services/maestro/processes/process-instances.ts index 8015c5c06..6dddb2f61 100644 --- a/src/services/maestro/processes/process-instances.ts +++ b/src/services/maestro/processes/process-instances.ts @@ -19,14 +19,14 @@ import { MAESTRO_ENDPOINTS } from '../../../utils/constants/endpoints'; import { createHeaders } from '../../../utils/http/headers'; import { FOLDER_KEY, CONTENT_TYPES } from '../../../utils/constants/headers'; import { transformData } from '../../../utils/transform'; -import { ProcessInstanceMap, ProcessInstanceExecutionHistoryMap } from '../../../models/maestro/process-instances.constants'; +import { ProcessInstanceMap } from '../../../models/maestro/process-instances.constants'; import { BpmnXmlString } from '../../../models/maestro/process-instances.types'; import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../../utils/pagination'; import { PaginationHelpers } from '../../../utils/pagination/helpers'; import { PaginationType } from '../../../utils/pagination/internal-types'; import { PROCESS_INSTANCE_PAGINATION, PROCESS_INSTANCE_TOKEN_PARAMS } from '../../../utils/constants/common'; import { track } from '../../../core/telemetry'; -import { BpmnVariableMetadata } from '../../../models/maestro/process-instances.internal-types'; +import { BpmnVariableMetadata, ElementExecutionsApiResponse, TraceSpan } from '../../../models/maestro/process-instances.internal-types'; export class ProcessInstancesService extends BaseService implements ProcessInstancesServiceModel { @@ -119,14 +119,62 @@ export class ProcessInstancesService extends BaseService implements ProcessInsta /** * Get execution history (spans) for a process instance * @param instanceId The ID of the instance to get history for + * @param folderKey The folder key for authorization * @returns Promise */ @track('ProcessInstances.GetExecutionHistory') - async getExecutionHistory(instanceId: string): Promise { - const response = await this.get(MAESTRO_ENDPOINTS.INSTANCES.GET_EXECUTION_HISTORY(instanceId)); - return response.data.map(historyItem => - transformData(historyItem, ProcessInstanceExecutionHistoryMap) + async getExecutionHistory(instanceId: string, folderKey: string): Promise { + // Call element-executions API to get structural BPMN data and traceId + const elementExecResponse = await this.get( + MAESTRO_ENDPOINTS.INSTANCES.GET_ELEMENT_EXECUTIONS(instanceId), + { + headers: createHeaders({ [FOLDER_KEY]: folderKey }) + } ); + + const traceId = elementExecResponse.data.instanceId; + + // Call spans API with traceId to get trace/span details + const spansResponse = await this.get( + MAESTRO_ENDPOINTS.TRACES.GET_SPANS(traceId), + { + headers: createHeaders({ [FOLDER_KEY]: folderKey }) + } + ); + + // Create span lookup by Id for merging + const spanMap = new Map(); + for (const span of spansResponse.data) { + spanMap.set(span.Id, span); + } + + // Merge: for each elementRun, find matching span and map to response type + const results: ProcessInstanceExecutionHistoryResponse[] = []; + + for (const elementExec of elementExecResponse.data.elementExecutions) { + for (const run of elementExec.elementRuns) { + const span = spanMap.get(run.elementRunId); + if (span) { + results.push(this.mapSpanToHistory(span)); + } + } + } + + return results; + } + + private mapSpanToHistory(span: TraceSpan): ProcessInstanceExecutionHistoryResponse { + return { + id: span.Id, + traceId: span.TraceId, + parentId: span.ParentId, + name: span.Name, + startedTime: span.StartTime, + endTime: span.EndTime, + attributes: span.Attributes, + updatedTime: span.UpdatedAt, + expiredTime: span.ExpiryTimeUtc, + }; } /** diff --git a/src/utils/constants/endpoints/base.ts b/src/utils/constants/endpoints/base.ts index db744dbea..2f012b18c 100644 --- a/src/utils/constants/endpoints/base.ts +++ b/src/utils/constants/endpoints/base.ts @@ -7,3 +7,4 @@ export const PIMS_BASE = 'pims_'; export const DATAFABRIC_BASE = 'datafabric_'; export const IDENTITY_BASE = 'identity_'; export const AUTOPILOT_BASE = 'autopilotforeveryone_'; +export const LLMOPS_BASE = 'llmopstenant_'; diff --git a/src/utils/constants/endpoints/maestro.ts b/src/utils/constants/endpoints/maestro.ts index 7f23d6a84..30cb28428 100644 --- a/src/utils/constants/endpoints/maestro.ts +++ b/src/utils/constants/endpoints/maestro.ts @@ -2,7 +2,7 @@ * Maestro Service Endpoints */ -import { PIMS_BASE } from './base'; +import { PIMS_BASE, LLMOPS_BASE } from './base'; /** * Maestro Process Service Endpoints @@ -15,7 +15,7 @@ export const MAESTRO_ENDPOINTS = { INSTANCES: { GET_ALL: `${PIMS_BASE}/api/v1/instances`, GET_BY_ID: (instanceId: string) => `${PIMS_BASE}/api/v1/instances/${instanceId}`, - GET_EXECUTION_HISTORY: (instanceId: string) => `${PIMS_BASE}/api/v1/spans/${instanceId}`, + GET_ELEMENT_EXECUTIONS: (instanceId: string) => `${PIMS_BASE}/api/v1/instances/${instanceId}/element-executions`, GET_BPMN: (instanceId: string) => `${PIMS_BASE}/api/v1/instances/${instanceId}/bpmn`, GET_VARIABLES: (instanceId: string) => `${PIMS_BASE}/api/v1/instances/${instanceId}/variables`, CANCEL: (instanceId: string) => `${PIMS_BASE}/api/v1/instances/${instanceId}/cancel`, @@ -27,6 +27,9 @@ export const MAESTRO_ENDPOINTS = { GET_BY_PROCESS: (processKey: string) => `${PIMS_BASE}/api/v1/incidents/process/${processKey}`, GET_BY_INSTANCE: (instanceId: string) => `${PIMS_BASE}/api/v1/instances/${instanceId}/incidents`, }, + TRACES: { + GET_SPANS: (traceId: string) => `${LLMOPS_BASE}/api/Traces/spans?traceId=${traceId}`, + }, CASES: { GET_CASE_JSON: (instanceId: string) => `${PIMS_BASE}/api/v1/cases/${instanceId}/case-json`, GET_ELEMENT_EXECUTIONS: (instanceId: string) => `${PIMS_BASE}/api/v1/element-executions/case-instances/${instanceId}`, diff --git a/tests/integration/shared/maestro/process-instances.integration.test.ts b/tests/integration/shared/maestro/process-instances.integration.test.ts index b037c257c..bf4ba7262 100644 --- a/tests/integration/shared/maestro/process-instances.integration.test.ts +++ b/tests/integration/shared/maestro/process-instances.integration.test.ts @@ -249,7 +249,7 @@ describe.each(modes)('Maestro Process Instances - Integration Tests [%s]', (mode const { processInstances } = getServices(); try { - const result = await processInstances.getExecutionHistory(testInstanceId); + const result = await processInstances.getExecutionHistory(testInstanceId, config.folderId || ''); expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); diff --git a/tests/unit/models/maestro/process-instances.test.ts b/tests/unit/models/maestro/process-instances.test.ts index c15ca9750..a5131148d 100644 --- a/tests/unit/models/maestro/process-instances.test.ts +++ b/tests/unit/models/maestro/process-instances.test.ts @@ -230,19 +230,20 @@ describe('Process Instance Models', () => { }); describe('processInstance.getExecutionHistory()', () => { - it('should call processInstance.getExecutionHistory with bound instanceId', async () => { + it('should call processInstance.getExecutionHistory with bound instanceId and folderKey', async () => { const mockInstanceData = createMockProcessInstance(); const instance = createProcessInstanceWithMethods(mockInstanceData, mockService); - + const mockHistory = [createMockExecutionHistory()]; mockService.getExecutionHistory = vi.fn().mockResolvedValue(mockHistory); - + const result = await instance.getExecutionHistory(); - + expect(mockService.getExecutionHistory).toHaveBeenCalledWith( - MAESTRO_TEST_CONSTANTS.INSTANCE_ID + MAESTRO_TEST_CONSTANTS.INSTANCE_ID, + MAESTRO_TEST_CONSTANTS.FOLDER_KEY ); expect(result).toEqual(mockHistory); }); @@ -252,9 +253,18 @@ describe('Process Instance Models', () => { const invalidInstanceData = { ...mockInstanceData, instanceId: undefined as any }; const invalidInstance = createProcessInstanceWithMethods(invalidInstanceData, mockService); - + await expect(invalidInstance.getExecutionHistory()).rejects.toThrow('Process instance ID is undefined'); }); + + it('should throw error if folderKey is undefined', async () => { + const mockInstanceData = createMockProcessInstance(); + const invalidInstanceData = { ...mockInstanceData, folderKey: undefined as any }; + const invalidInstance = createProcessInstanceWithMethods(invalidInstanceData, mockService); + + + await expect(invalidInstance.getExecutionHistory()).rejects.toThrow('Process instance folder key is undefined'); + }); }); describe('processInstance.getBpmn()', () => { diff --git a/tests/unit/services/maestro/process-instances.test.ts b/tests/unit/services/maestro/process-instances.test.ts index 0dec9bf85..1a43c5f7d 100644 --- a/tests/unit/services/maestro/process-instances.test.ts +++ b/tests/unit/services/maestro/process-instances.test.ts @@ -10,7 +10,8 @@ import { TEST_CONSTANTS, createMockProcessInstance, createMockBpmnWithVariables, - createMockExecutionHistory, + createMockElementExecutionsResponse, + createMockTraceSpan, createMockProcessVariables, createMockMaestroApiOperationResponse } from '../../../utils/mocks'; @@ -208,33 +209,65 @@ describe('ProcessInstancesService', () => { describe('getExecutionHistory', () => { it('should return execution history for process instance', async () => { - const instanceId = MAESTRO_TEST_CONSTANTS.INSTANCE_ID; - const mockApiResponse: ProcessInstanceExecutionHistoryResponse[] = [createMockExecutionHistory()]; + const folderKey = MAESTRO_TEST_CONSTANTS.FOLDER_KEY; - mockApiClient.get.mockResolvedValue(mockApiResponse); + mockApiClient.get + .mockResolvedValueOnce(createMockElementExecutionsResponse()) + .mockResolvedValueOnce([createMockTraceSpan()]); - - const result = await service.getExecutionHistory(instanceId); + const result = await service.getExecutionHistory(instanceId, folderKey); + + expect(mockApiClient.get).toHaveBeenCalledWith( + MAESTRO_ENDPOINTS.INSTANCES.GET_ELEMENT_EXECUTIONS(instanceId), + { + headers: expect.objectContaining({ + [FOLDER_KEY]: folderKey + }) + } + ); - expect(mockApiClient.get).toHaveBeenCalledWith( - MAESTRO_ENDPOINTS.INSTANCES.GET_EXECUTION_HISTORY(instanceId), - {} + MAESTRO_ENDPOINTS.TRACES.GET_SPANS(instanceId), + { + headers: expect.objectContaining({ + [FOLDER_KEY]: folderKey + }) + } ); expect(result).toHaveLength(1); expect(result[0]).toHaveProperty('id', MAESTRO_TEST_CONSTANTS.SPAN_ID); expect(result[0]).toHaveProperty('traceId', MAESTRO_TEST_CONSTANTS.TRACE_ID); + expect(result[0]).toHaveProperty('name', MAESTRO_TEST_CONSTANTS.ACTIVITY_NAME); + }); + + it('should only include spans matched to elementRuns', async () => { + const instanceId = MAESTRO_TEST_CONSTANTS.INSTANCE_ID; + const folderKey = MAESTRO_TEST_CONSTANTS.FOLDER_KEY; + const unmatchedSpan = createMockTraceSpan({ + Id: 'nested-agent-span-1', + ParentId: MAESTRO_TEST_CONSTANTS.SPAN_ID, + Name: 'LangGraph', + Attributes: null + }); + + mockApiClient.get + .mockResolvedValueOnce(createMockElementExecutionsResponse()) + .mockResolvedValueOnce([createMockTraceSpan(), unmatchedSpan]); + + const result = await service.getExecutionHistory(instanceId, folderKey); + + // Only the matched elementRun span should be included + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('id', MAESTRO_TEST_CONSTANTS.SPAN_ID); }); it('should handle API errors', async () => { - const error = new Error(TEST_CONSTANTS.ERROR_MESSAGE); mockApiClient.get.mockRejectedValue(error); - - await expect(service.getExecutionHistory(MAESTRO_TEST_CONSTANTS.INSTANCE_ID)).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + await expect(service.getExecutionHistory(MAESTRO_TEST_CONSTANTS.INSTANCE_ID, MAESTRO_TEST_CONSTANTS.FOLDER_KEY)).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); diff --git a/tests/utils/mocks/maestro.ts b/tests/utils/mocks/maestro.ts index af510611a..690f7e9b8 100644 --- a/tests/utils/mocks/maestro.ts +++ b/tests/utils/mocks/maestro.ts @@ -87,15 +87,63 @@ export const createMockExecutionHistory = (overrides: Partial = {}) => { traceId: MAESTRO_TEST_CONSTANTS.TRACE_ID, parentId: null, name: MAESTRO_TEST_CONSTANTS.ACTIVITY_NAME, - startedTime: new Date().toISOString(), - endTime: new Date().toISOString(), + startedTime: MAESTRO_TEST_CONSTANTS.START_TIME, + endTime: MAESTRO_TEST_CONSTANTS.END_TIME, attributes: MAESTRO_TEST_CONSTANTS.ATTRIBUTES, - createdTime: new Date().toISOString(), - updatedTime: new Date().toISOString(), + updatedTime: MAESTRO_TEST_CONSTANTS.END_TIME, expiredTime: null }, overrides); }; +export const createMockElementExecutionsResponse = (overrides: Partial = {}) => { + return createMockBaseResponse({ + instanceId: MAESTRO_TEST_CONSTANTS.INSTANCE_ID, + elementExecutions: [ + { + elementId: 'Event_start', + elementType: 'StartEvent', + elementExtensionType: null, + status: 'Completed', + startedTimeUtc: MAESTRO_TEST_CONSTANTS.START_TIME, + completedTimeUtc: MAESTRO_TEST_CONSTANTS.END_TIME, + parentRunId: null, + parentElementId: null, + parentElementRunId: null, + runId: '', + elementRuns: [ + { + elementRunId: MAESTRO_TEST_CONSTANTS.SPAN_ID, + status: 'Completed', + startedTimeUtc: MAESTRO_TEST_CONSTANTS.START_TIME, + completedTimeUtc: MAESTRO_TEST_CONSTANTS.END_TIME, + incomingFlowId: null, + incomingFlowIds: [], + markerItemIndex: null, + workflowId: null, + temporalExecutionId: null, + version: 2, + parentElementRunId: null + } + ] + } + ] + }, overrides); +}; + +export const createMockTraceSpan = (overrides: Partial = {}) => { + return createMockBaseResponse({ + Id: MAESTRO_TEST_CONSTANTS.SPAN_ID, + TraceId: MAESTRO_TEST_CONSTANTS.TRACE_ID, + ParentId: null, + Name: MAESTRO_TEST_CONSTANTS.ACTIVITY_NAME, + StartTime: MAESTRO_TEST_CONSTANTS.START_TIME, + EndTime: MAESTRO_TEST_CONSTANTS.END_TIME, + Attributes: MAESTRO_TEST_CONSTANTS.ATTRIBUTES, + ExpiryTimeUtc: null, + UpdatedAt: MAESTRO_TEST_CONSTANTS.END_TIME + }, overrides); +}; + /** * Creates a mock Process Instance Variables response * @param overrides - Optional overrides for specific fields