UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

888 lines (887 loc) 146 kB
/** * Update Template Handler * * Handles UPDATE operations that contain template-like structures (e.g., ab_test) * and converts them into the correct sequence of API operations. * * This bridges the gap between: * - EntityOrchestrator (CREATE with ab_test templates) ← WORKS * - EntityRouter (UPDATE with ab_test structures) ← WAS FAILING * * @author Optimizely MCP Server * @version 1.0.0 */ import { getLogger } from '../logging/Logger.js'; import { TrafficAllocationCalculator } from '../utils/TrafficAllocationCalculator.js'; export class UpdateTemplateHandler { logger = getLogger(); entityRouter; constructor(entityRouter) { this.entityRouter = entityRouter; } /** * Check if an update operation requires template handling */ requiresTemplateHandling(entityType, operation, data) { // Handle both flag and experiment updates if (operation !== 'update') { return false; } // Only flags, experiments, and pages support template handling if (entityType !== 'flag' && entityType !== 'experiment' && entityType !== 'page') { return false; } // Check for template-specific fields if (entityType === 'page') { // Page-specific template fields return !!(data?.conditions || data?.entity_data?.conditions || data?.activation_type || data?.entity_data?.activation_type || data?.activation_code || data?.entity_data?.activation_code); } // Flag and experiment template fields return !!(data?.ab_test || data?.entity_data?.ab_test || data?.new_variation || data?.entity_data?.new_variation || data?.traffic_allocation || data?.entity_data?.traffic_allocation || data?.variables || data?.entity_data?.variables || data?.variable_definitions || data?.entity_data?.variable_definitions || data?.audience_conditions || data?.entity_data?.audience_conditions || data?.metrics || data?.entity_data?.metrics || data?.variations || data?.entity_data?.variations || data?.remove_variations || data?.entity_data?.remove_variations); } /** * Handle update operation with template-like structure */ async handleTemplateUpdate(entityType, projectId, entityId, data, options = {}) { this.logger.debug({ data }, 'handleTemplateUpdate ENTRY - RAW data parameter'); this.logger.info({ entityType, projectId, entityId, hasAbTest: !!(data?.ab_test || data?.entity_data?.ab_test) }, 'UpdateTemplateHandler: Processing template update'); this.logger.debug({ step: 'ROUTING_DEBUG', entityType, dataKeys: data ? Object.keys(data) : 'null', hasNewVariation: !!(data?.new_variation || data?.entity_data?.new_variation), hasTrafficAllocation: !!(data?.traffic_allocation || data?.entity_data?.traffic_allocation), hasAbTest: !!(data?.ab_test || data?.entity_data?.ab_test), hasVariables: !!(data?.variables || data?.entity_data?.variables || data?.variable_definitions || data?.entity_data?.variable_definitions), hasAudienceConditions: !!(data?.audience_conditions || data?.entity_data?.audience_conditions), hasMetrics: !!(data?.metrics || data?.entity_data?.metrics), hasVariations: !!(data?.variations || data?.entity_data?.variations), newVariationValue: data?.new_variation, entityDataNewVariation: data?.entity_data?.new_variation, trafficAllocationValue: data?.traffic_allocation, entityDataTrafficAllocation: data?.entity_data?.traffic_allocation, variablesValue: data?.variables || data?.variable_definitions, entityDataVariables: data?.entity_data?.variables || data?.entity_data?.variable_definitions, audienceConditionsValue: data?.audience_conditions, entityDataAudienceConditions: data?.entity_data?.audience_conditions, metricsValue: data?.metrics, entityDataMetrics: data?.entity_data?.metrics, variationsValue: data?.variations, entityDataVariations: data?.entity_data?.variations, whichConditionWillTrigger: (entityType === 'flag' && (data?.ab_test || data?.entity_data?.ab_test)) ? 'AB_TEST' : (entityType === 'flag' && (data?.new_variation || data?.entity_data?.new_variation)) ? 'NEW_VARIATION' : (entityType === 'flag' && (data?.traffic_allocation || data?.entity_data?.traffic_allocation)) ? 'TRAFFIC_ALLOCATION' : (entityType === 'flag' && (data?.variables || data?.entity_data?.variables || data?.variable_definitions || data?.entity_data?.variable_definitions)) ? 'VARIABLES' : (entityType === 'flag' && (data?.audience_conditions || data?.entity_data?.audience_conditions)) ? 'AUDIENCE_CONDITIONS' : (entityType === 'flag' && (data?.metrics || data?.entity_data?.metrics)) ? 'METRICS' : (entityType === 'flag' && (data?.variations || data?.entity_data?.variations)) ? 'VARIATIONS' : 'NONE' }, 'Critical routing debug'); try { if (entityType === 'flag' && (data?.ab_test || data?.entity_data?.ab_test)) { this.logger.debug('Routing to: handleFlagAbTestUpdate'); return await this.handleFlagAbTestUpdate(projectId, entityId, data, options); } // CRITICAL FIX: Handle combined templates first to prevent dual routing if (entityType === 'flag' && (data?.new_variation || data?.entity_data?.new_variation) && (data?.traffic_allocation || data?.entity_data?.traffic_allocation)) { this.logger.debug('Routing to: handleFlagAddVariationUpdate (COMBINED new_variation + traffic_allocation)'); return await this.handleFlagAddVariationUpdate(projectId, entityId, data, options); } if (entityType === 'flag' && (data?.new_variation || data?.entity_data?.new_variation)) { this.logger.debug('Routing to: handleFlagAddVariationUpdate (new_variation only)'); return await this.handleFlagAddVariationUpdate(projectId, entityId, data, options); } if (entityType === 'flag' && (data?.traffic_allocation || data?.entity_data?.traffic_allocation)) { this.logger.debug('Routing to: handleFlagTrafficUpdate (traffic_allocation only)'); return await this.handleFlagTrafficUpdate(projectId, entityId, data, options); } if (entityType === 'flag' && (data?.variables || data?.entity_data?.variables || data?.variable_definitions || data?.entity_data?.variable_definitions)) { this.logger.debug('Routing to: handleFlagVariablesUpdate'); return await this.handleFlagVariablesUpdate(projectId, entityId, data, options); } if (entityType === 'flag' && (data?.audience_conditions || data?.entity_data?.audience_conditions)) { this.logger.debug('Routing to: handleFlagAudienceUpdate'); return await this.handleFlagAudienceUpdate(projectId, entityId, data, options); } // Handle metrics updates if (entityType === 'flag' && (data?.metrics || data?.entity_data?.metrics)) { this.logger.debug('Routing to: handleFlagMetricsUpdate'); return await this.handleFlagMetricsUpdate(projectId, entityId, data, options); } // Handle variations updates if (entityType === 'flag' && (data?.variations || data?.entity_data?.variations)) { this.logger.debug('Routing to: handleFlagVariationsUpdate'); return await this.handleFlagVariationsUpdate(projectId, entityId, data, options); } // Handle experiment updates if (entityType === 'experiment') { this.logger.debug('Routing to: handleExperimentUpdate'); return await this.handleExperimentUpdate(projectId, entityId, data, options); } // Handle page updates if (entityType === 'page') { if (data?.conditions || data?.entity_data?.conditions) { this.logger.debug('Routing to: handlePageConditionsUpdate'); return await this.handlePageConditionsUpdate(projectId, entityId, data, options); } if (data?.activation_type || data.entity_data?.activation_type || data?.activation_code || data?.entity_data?.activation_code) { this.logger.debug('Routing to: handlePageActivationUpdate'); return await this.handlePageActivationUpdate(projectId, entityId, data, options); } } return { success: false, requiresTemplateHandling: false, error: 'Unsupported template structure for update operation' }; } catch (error) { this.logger.error({ error: error?.message }, 'UpdateTemplateHandler: Template update failed'); return { success: false, requiresTemplateHandling: true, error: error?.message || 'Unknown error occurred' }; } } /** * Handle flag update with ab_test structure */ async handleFlagAbTestUpdate(projectId, flagId, data, options) { const abTest = data.ab_test || data.entity_data?.ab_test; const operations = []; let stepCounter = 1; this.logger.warn({ rawData: JSON.stringify(data, null, 2), hasAbTest: !!data.ab_test, hasEntityDataAbTest: !!data.entity_data?.ab_test, abTestPath: data.ab_test ? 'data.ab_test' : data.entity_data?.ab_test ? 'data.entity_data.ab_test' : 'none' }, 'DEBUG: Raw data received by UpdateTemplateHandler'); this.logger.info({ flagId, abTestStructure: { hasVariations: !!abTest.variations, variationCount: abTest.variations?.length || 0, hasEnvironment: !!abTest.environment, hasMetrics: !!abTest.metrics, variationDetails: abTest.variations?.map((v, i) => ({ index: i, hasKey: !!v.key, key: v.key, hasName: !!v.name, hasWeight: v.weight !== undefined, feature_enabled: v.feature_enabled, allFields: Object.keys(v || {}) })) } }, 'UpdateTemplateHandler: Processing flag ab_test update'); // DEBUG: Log the complete ab_test structure this.logger.debug({ completeAbTest: abTest, variationsRaw: abTest.variations }, 'UpdateTemplateHandler: Complete ab_test structure received'); try { // Step 1: Get the flag to understand its current state const flagResult = await this.entityRouter.handleGetOperation('flag', projectId, flagId); if (!flagResult) { throw new Error(`Flag '${flagId}' not found`); } // Detect platform early for use throughout the method const projectTypeInfo = await this.entityRouter.getProjectType(projectId); const platform = projectTypeInfo.projectType === 'feature' ? 'feature' : 'web'; // Step 2: Get ALL existing variations on the flag BEFORE trying to create any let allFlagVariations = []; try { const variationsResponse = await this.entityRouter.routeEntityOperation({ operation: 'list', entityType: 'variation', projectId, filters: { flag_key: flagResult.key } }); allFlagVariations = variationsResponse?.entities || variationsResponse?.data || variationsResponse || []; this.logger.info({ flagKey: flagResult.key, totalFlagVariations: allFlagVariations.length, allVariationKeys: allFlagVariations.map((v) => v.key), templateVariationKeys: abTest.variations?.map((v) => v.key) || [] }, 'Retrieved all existing flag variations before processing AB test template'); } catch (error) { this.logger.warn({ error: error instanceof Error ? error.message : 'Unknown error', flagKey: flagResult.key }, 'Failed to retrieve flag variations, will attempt creation for all'); } // Step 3: Create variations ONLY if they don't already exist if (abTest.variations && Array.isArray(abTest.variations)) { for (const variation of abTest.variations) { // Check if this variation already exists const existingVariation = allFlagVariations.find(v => v.key === variation.key); if (existingVariation) { this.logger.info({ variationKey: variation.key, variationId: existingVariation.id, flagKey: flagResult.key }, 'Variation already exists on flag, skipping creation'); continue; // Skip to next variation } // DEBUG: Log the variation data before processing this.logger.debug({ originalVariation: variation, hasKey: !!variation.key, hasName: !!variation.name, hasWeight: variation.weight !== undefined, allFields: Object.keys(variation || {}) }, 'UpdateTemplateHandler: Processing individual variation'); // CRITICAL FIX: Generate key/name if missing to prevent validation failures // This ensures variations always have required fields even if template processing stripped them if (!variation.key) { variation.key = `variation_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; this.logger.warn({ generatedKey: variation.key, index: abTest.variations.indexOf(variation) }, 'Generated missing variation key'); } if (!variation.name) { variation.name = variation.key || `Variation ${abTest.variations.indexOf(variation) + 1}`; this.logger.warn({ generatedName: variation.name, index: abTest.variations.indexOf(variation) }, 'Generated missing variation name'); } // Pass variation data directly - ComprehensiveAutoCorrector will handle missing keys and weight conversion let variationWeight = variation.weight; // If weight is not provided, calculate default weight in basis points if (!variationWeight) { variationWeight = Math.floor(10000 / abTest.variations.length); } else { // If weight is provided, ensure it's in basis points using TrafficAllocationCalculator try { variationWeight = TrafficAllocationCalculator.percentageToBasisPoints(variationWeight); } catch (error) { this.logger.warn({ originalWeight: variation.weight, error: error instanceof Error ? error.message : 'Unknown error' }, 'Failed to convert variation weight, using fallback calculation'); variationWeight = Math.floor(10000 / abTest.variations.length); } } // Create platform-specific variation payload // Feature Experimentation only accepts: key, name, description, variables // Web Experimentation accepts: key, name, weight, feature_enabled, variable_values const variationPayload = { key: variation.key, name: variation.name }; // Add platform-specific fields based on project type // Platform already detected at the beginning of the method if (platform === 'feature') { // Feature Experimentation - use variables instead of variable_values, no weight/feature_enabled if (variation.variable_values || variation.variables) { variationPayload.variables = variation.variables || variation.variable_values || {}; } if (variation.description) { variationPayload.description = variation.description; } } else { // Web Experimentation - include weight, feature_enabled, variable_values variationPayload.weight = variationWeight; variationPayload.feature_enabled = variation.feature_enabled !== undefined ? variation.feature_enabled : true; variationPayload.variable_values = variation.variable_values || {}; } // DEBUG: Log the payload being sent to EntityRouter this.logger.debug({ variationPayload, hasKey: !!variationPayload.key, hasName: !!variationPayload.name, keyValue: variationPayload.key, nameValue: variationPayload.name, platform }, 'UpdateTemplateHandler: Sending variation payload to EntityRouter'); const createVariationResult = await this.entityRouter.handleCreateOperation('variation', projectId, variationPayload, { flag_key: flagResult.key, isTemplateMode: true, platform }); operations.push({ step: stepCounter++, description: `Created variation: ${createVariationResult.key || variation.key || 'unknown'}`, operation: 'create', entity_type: 'variation', data: { key: createVariationResult.key || variation.key, name: createVariationResult.name || variation.name, weight: variation.weight || Math.floor(10000 / abTest.variations.length) }, result: createVariationResult }); } } // Step 3: Create or resolve events for metrics const eventIdMap = {}; if (abTest.metrics && Array.isArray(abTest.metrics)) { for (const metric of abTest.metrics) { // Generate default event_key if missing let eventKey = metric.event_key || metric.key; if (!eventKey) { eventKey = `${flagResult.key}_metric_${Date.now()}`; this.logger.info(`Generated missing event_key: ${eventKey} for metric`); } // CRITICAL: Always update the metric with the resolved event_key for later use metric.event_key = eventKey; if (eventKey && !metric.event_id) { try { // First try to find existing event const existingEvents = await this.entityRouter.routeEntityOperation({ operation: 'list', entityType: 'event', projectId, filters: { key: eventKey } }); if (existingEvents && existingEvents.length > 0) { eventIdMap[eventKey] = existingEvents[0].id; this.logger.info(`Found existing event ${eventKey} with ID ${existingEvents[0].id}`); } else { // Create new event const createEventResult = await this.entityRouter.routeEntityOperation({ operation: 'create', entityType: 'event', projectId, data: { key: eventKey, name: metric.name || eventKey, event_type: metric.event_type || 'custom' } }); // Handle envelope response format (result.data || result) const eventData = createEventResult.data || createEventResult; eventIdMap[eventKey] = eventData.id; this.logger.info(`Created event ${eventKey} with ID ${eventData.id} and stored in eventIdMap`); operations.push({ step: stepCounter++, description: `Created event: ${eventKey}`, operation: 'create', entity_type: 'event', data: { key: eventKey, name: metric.name || eventKey, event_type: metric.event_type || 'custom' }, result: createEventResult }); } } catch (error) { this.logger.warn(`Failed to create/find event ${eventKey}: ${error}`); } } else if (metric.event_id) { // Already have event ID eventIdMap[metric.event_key || metric.key] = metric.event_id; } } } // DEBUG: Log the final eventIdMap before building metrics this.logger.info({ eventIdMapKeys: Object.keys(eventIdMap), eventIdMapEntries: Object.entries(eventIdMap), totalEventIds: Object.keys(eventIdMap).length }, 'DEBUG: Final eventIdMap before building metrics'); // Step 4: Update the ruleset to add A/B test rule const environmentKey = abTest.environment?.ref?.key || abTest.environment || 'development'; // Build variations structure for Feature Experimentation const variations = abTest.variations?.map((v) => ({ key: v.key, name: v.name || v.key, traffic_allocation: v.weight || Math.floor(10000 / abTest.variations.length) })) || []; // Build metrics with required fields and resolved event IDs const metrics = abTest.metrics?.map((m) => { // Use the event_key that was set during event processing (including generated ones) let eventKey = m.event_key || m.key; if (!eventKey) { // This shouldn't happen now since we generate event_key above, but just in case eventKey = `${flagResult.key}_metric_${Date.now()}`; this.logger.warn(`Generated fallback event_key: ${eventKey} for metric`); } const eventId = m.event_id || eventIdMap[eventKey]; // DEBUG: Log the eventIdMap lookup process this.logger.info({ metricEventKey: eventKey, eventIdMapKeys: Object.keys(eventIdMap), eventIdMapValues: Object.values(eventIdMap), foundEventId: eventId, eventIdMapLookupResult: eventIdMap[eventKey] }, `DEBUG: Event ID resolution for metric ${eventKey}`); // Build the metric object - event_id is optional in Feature Experimentation const metric = { aggregator: m.aggregator === 'total' ? 'sum' : (m.aggregator || 'unique'), scope: m.scope || 'visitor', winning_direction: m.winning_direction || 'increasing' }; // Add event_id if we have it (preferred), otherwise event_key is sufficient if (eventId) { metric.event_id = eventId; this.logger.info(`Using event_id ${eventId} for metric ${eventKey}`); } else { // Feature Experimentation allows metrics without event_id for global metrics this.logger.info(`No event ID found for metric ${eventKey}, proceeding without event_id (valid for global metrics)`); } // Add field parameter when aggregator is 'sum' (required by API) if (metric.aggregator === 'sum') { metric.field = m.field || 'value'; // Default to 'value' if not specified } return metric; }) || []; // Generate rule key: use user-provided key or generate from flag name + timestamp let ruleKey; if (abTest.rule_key) { // User provided a rule key - use it exactly as provided (hard stop if duplicate) ruleKey = abTest.rule_key; this.logger.info(`Using user-provided rule key: ${ruleKey}`); } else { // Generate rule key: {flag_name}_rule_{MMDDYYYYHHMMSS} const now = new Date(); const timestamp = String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0') + now.getFullYear() + String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0'); // Build the rule key const baseKey = `${flagResult.key}_rule_${timestamp}`; // Ensure it's <= 64 characters (truncate flag name if needed) if (baseKey.length > 64) { const maxFlagLength = 64 - '_rule_'.length - timestamp.length; const truncatedFlag = flagResult.key.substring(0, maxFlagLength); ruleKey = `${truncatedFlag}_rule_${timestamp}`; this.logger.warn(`Rule key truncated from ${baseKey.length} to 64 characters: ${ruleKey}`); } else { ruleKey = baseKey; } this.logger.info(`Generated rule key: ${ruleKey}`); } const rulesetUpdateData = [ { op: 'replace', path: '/enabled', value: true }, { op: 'add', path: `/rules/${ruleKey}`, value: { key: ruleKey, name: abTest.rule_name || `A/B Test for ${flagResult.name || flagResult.key}`, type: 'a/b', // Correct type value percentage_included: 10000, audience_conditions: abTest.audience_conditions === 'everyone' || !abTest.audience_conditions ? [] : abTest.audience_conditions, enabled: true, actions: [{ changes: [{ type: 'apply_feature_variables', value: { variations } }] }], metrics: metrics } }, { op: 'add', path: '/rule_priorities/0', value: ruleKey } ]; const rulesetResult = await this.entityRouter.routeEntityOperation({ operation: 'update', entityType: 'ruleset', projectId, data: rulesetUpdateData, options: { flag_key: flagResult.key, environment_key: environmentKey } }); operations.push({ step: stepCounter++, description: `Updated ruleset with A/B test rule in ${environmentKey} environment`, operation: 'update', entity_type: 'ruleset', data: rulesetUpdateData, result: rulesetResult }); // Note: In Feature Experimentation, A/B tests are fully configured through rulesets // No separate experiment entity is needed or created return { success: true, requiresTemplateHandling: true, operations, guidance: `Successfully converted flag update with ab_test structure into ${operations.length} separate operations. The flag '${flagResult.key}' now has A/B testing configured via variations and ruleset rules.` }; } catch (error) { this.logger.error({ error: error?.message, errorStack: error?.stack, errorResponse: error?.response?.data, errorStatus: error?.response?.status, errorUrl: error?.config?.url, operationsCompleted: operations.length }, 'UpdateTemplateHandler: Flag ab_test update failed'); // Extract the actual API error details let errorDetails = error?.message || 'Unknown error'; if (error?.response?.data) { // API returned an error response - this has the REAL error details if (typeof error.response.data === 'string') { errorDetails = error.response.data; } else if (error.response.data.message) { errorDetails = error.response.data.message; } else if (error.response.data.error) { errorDetails = error.response.data.error; } else { // Stringify the entire error response to ensure we don't miss anything errorDetails = JSON.stringify(error.response.data); } } throw new Error(`Failed to process flag ab_test update: ${errorDetails}. Completed ${operations.length} operations before failure.`); } } /** * Handle flag update with new_variation structure (flag_add_variation template) */ async handleFlagAddVariationUpdate(projectId, flagId, data, options) { const newVariation = data.new_variation || data.entity_data?.new_variation; const environmentKey = data.environment || data.entity_data?.environment || 'development'; const rebalanceExisting = data.rebalance_existing || data.entity_data?.rebalance_existing || true; const trafficAllocation = data.traffic_allocation || data.entity_data?.traffic_allocation; const operations = []; let stepCounter = 1; this.logger.info({ flagId, newVariation, environmentKey, rebalanceExisting, trafficAllocation, hasTrafficAllocation: !!trafficAllocation }, 'UpdateTemplateHandler: Processing flag add variation update'); try { // Step 1: Get the flag to understand its current state const flagResult = await this.entityRouter.handleGetOperation('flag', projectId, flagId); if (!flagResult) { throw new Error(`Flag '${flagId}' not found`); } // Detect platform early for use throughout the method const projectTypeInfo = await this.entityRouter.getProjectType(projectId); const platform = projectTypeInfo.projectType === 'feature' ? 'feature' : 'web'; // Step 2: Get ALL variations that exist on the flag BEFORE trying to create let allFlagVariations = []; let existingVariation = null; try { const variationsResponse = await this.entityRouter.routeEntityOperation({ operation: 'list', entityType: 'variation', projectId, filters: { flag_key: flagResult.key } }); allFlagVariations = variationsResponse?.entities || variationsResponse?.data || variationsResponse || []; // Check if the new variation already exists existingVariation = allFlagVariations.find(v => v.key === newVariation.key); this.logger.info({ flagKey: flagResult.key, totalFlagVariations: allFlagVariations.length, allVariationKeys: allFlagVariations.map((v) => v.key), newVariationKey: newVariation.key, alreadyExists: !!existingVariation }, 'Retrieved all flag variations before creation attempt'); } catch (error) { this.logger.warn({ error: error instanceof Error ? error.message : 'Unknown error', flagKey: flagResult.key }, 'Failed to retrieve flag variations, continuing with creation'); } // Step 3: Create the new variation ONLY if it doesn't already exist let createdVariation = null; if (existingVariation) { this.logger.info({ variationKey: newVariation.key, variationId: existingVariation.id }, 'Variation already exists, skipping creation'); createdVariation = existingVariation; } else { const variationPayload = { key: newVariation.key, name: newVariation.name || newVariation.key }; // Add platform-specific fields // Note: platform and projectTypeInfo are defined at the method level if (platform === 'feature') { // Feature Experimentation - use variables instead of variable_values, no weight/feature_enabled if (newVariation.variable_values || newVariation.variables) { variationPayload.variables = newVariation.variables || newVariation.variable_values || {}; } if (newVariation.description) { variationPayload.description = newVariation.description; } } else { // Web Experimentation - include weight, feature_enabled, variable_values // Convert weight to basis points if provided let variationWeight = 5000; // Default 50% if (newVariation.weight !== undefined && newVariation.weight !== null) { try { variationWeight = TrafficAllocationCalculator.percentageToBasisPoints(newVariation.weight); this.logger.info({ originalWeight: newVariation.weight, convertedWeight: variationWeight }, 'Converted variation weight to basis points'); } catch (error) { this.logger.warn({ originalWeight: newVariation.weight, error: error instanceof Error ? error.message : 'Unknown error' }, 'Failed to convert variation weight, using default 5000'); variationWeight = 5000; } } variationPayload.weight = variationWeight; variationPayload.feature_enabled = newVariation.feature_enabled !== undefined ? newVariation.feature_enabled : true; variationPayload.variable_values = newVariation.variable_values || {}; } createdVariation = await this.entityRouter.handleCreateOperation('variation', projectId, variationPayload, { flag_key: flagResult.key, isTemplateMode: true, platform }); operations.push({ step: stepCounter++, description: `Created variation: ${createdVariation.key || newVariation.key}`, operation: 'create', entity_type: 'variation', data: variationPayload, result: createdVariation }); } // Step 3: Get the existing ruleset to find the A/B test rule this.logger.info({ step: 'ruleset_lookup', projectId, flag_key: flagResult.key, environmentKey, ruleset_fetch_params: { operation: 'get', entityType: 'ruleset', projectId, entityId: environmentKey, options: { flag_key: flagResult.key } } }, 'UpdateTemplateHandler: About to fetch existing ruleset for variation addition'); const rulesetResponse = await this.entityRouter.routeEntityOperation({ operation: 'get', entityType: 'ruleset', projectId, entityId: environmentKey, options: { flag_key: flagResult.key } }); this.logger.warn({ step: 'ruleset_response_debug', rulesetResponseType: typeof rulesetResponse, rulesetResponseKeys: rulesetResponse ? Object.keys(rulesetResponse) : null, hasData: !!rulesetResponse?.data, hasRules: !!rulesetResponse?.rules, dataHasRules: !!rulesetResponse?.data?.rules, rulesetResponse: JSON.stringify(rulesetResponse).substring(0, 500) }, 'UpdateTemplateHandler: Ruleset response received'); // CRITICAL FIX: Unwrap response envelope to get actual ruleset data // EntityRouter may return { result: "success", data: {ruleset} } instead of raw ruleset const existingRuleset = rulesetResponse?.data || rulesetResponse; this.logger.info({ step: 'ruleset_lookup_result', projectId, flag_key: flagResult.key, environmentKey, existingRuleset: existingRuleset ? { hasRules: Boolean(existingRuleset.rules), rulesCount: existingRuleset.rules ? Object.keys(existingRuleset.rules).length : 0, rulesetId: existingRuleset.id || 'no-id', rulesetKeys: existingRuleset.rules ? Object.keys(existingRuleset.rules) : [] } : null, isNull: existingRuleset === null, isUndefined: existingRuleset === undefined }, 'UpdateTemplateHandler: Ruleset fetch completed'); this.logger.debug({ step: 'CRITICAL_RULES_CHECK', projectId, flag_key: flagResult.key, environmentKey, existingRuleset_isNull: existingRuleset === null, existingRuleset_isUndefined: existingRuleset === undefined, existingRuleset_type: typeof existingRuleset, existingRuleset_keys: existingRuleset ? Object.keys(existingRuleset) : 'N/A', rules_property_exists: existingRuleset && 'rules' in existingRuleset, rules_value: existingRuleset ? existingRuleset.rules : 'N/A', rules_type: existingRuleset && existingRuleset.rules ? typeof existingRuleset.rules : 'N/A', rules_isNull: existingRuleset && existingRuleset.rules === null, rules_isUndefined: existingRuleset && existingRuleset.rules === undefined, EXACT_CHECK_RESULT: !existingRuleset || !existingRuleset.rules }, 'UpdateTemplateHandler: Critical rules check - exactly what the if condition sees'); if (!existingRuleset || !existingRuleset.rules) { // Instead of throwing an error, return workflow guidance return { success: false, requiresTemplateHandling: true, error: `No ruleset found for flag '${flagResult.key}' in environment '${environmentKey}'.`, guidance: `WORKFLOW_REQUIRED: Adding variations to flags requires specific workflow guidance. Use: get_entity_documentation(entity_type="flag_variation_addition") to get step-by-step instructions for this complex operation.` }; } // Find the existing A/B test rule (it should have metrics) let existingAbTestRule = null; let existingRuleKey = null; for (const [ruleKey, rule] of Object.entries(existingRuleset.rules)) { const typedRule = rule; if (typedRule.type === 'a/b' && typedRule.metrics && typedRule.metrics.length > 0) { existingAbTestRule = typedRule; existingRuleKey = ruleKey; break; } } if (!existingAbTestRule || !existingRuleKey) { // Instead of throwing an error, return workflow guidance return { success: false, requiresTemplateHandling: true, error: `No A/B test rule with metrics found for flag '${flagResult.key}'.`, guidance: `WORKFLOW_REQUIRED: Adding variations to flags requires specific workflow guidance. Use: get_entity_documentation(entity_type="flag_variation_addition") to get step-by-step instructions for this complex operation.` }; } this.logger.info({ flagKey: flagResult.key, environmentKey, foundRuleKey: existingRuleKey, existingVariations: existingAbTestRule.actions?.[0]?.changes?.[0]?.value?.variations, existingMetrics: existingAbTestRule.metrics?.length }, 'Found existing A/B test rule to update'); // Step 4: Calculate new traffic allocation // Note: existingVariations will be determined after platform detection let variationWeights = {}; if (trafficAllocation && Array.isArray(trafficAllocation)) { // Use custom traffic allocation provided in template data this.logger.info({ trafficAllocation, trafficAllocationLength: trafficAllocation.length }, 'Using custom traffic allocation from template data'); // Convert traffic allocation array to weights map for (const allocation of trafficAllocation) { let weight; if (typeof allocation.weight === 'string') { weight = parseInt(allocation.weight, 10); if (isNaN(weight)) { this.logger.warn({ originalWeight: allocation.weight, variation_key: allocation.variation_key }, 'Failed to parse weight string, using 0'); weight = 0; } } else { weight = Number(allocation.weight) || 0; } variationWeights[allocation.variation_key] = weight; this.logger.info({ variation_key: allocation.variation_key, originalWeight: allocation.weight, parsedWeight: weight, weightType: typeof allocation.weight }, 'Weight conversion debug'); } this.logger.info({ variationWeights, totalWeight: Object.values(variationWeights).reduce((sum, w) => sum + w, 0) }, 'Converted traffic allocation to weights map'); } else { // Fallback logic will be implemented after platform detection where existingVariations is available this.logger.warn({ hasTrafficAllocation: !!trafficAllocation, trafficAllocationType: typeof trafficAllocation, trafficAllocationIsArray: Array.isArray(trafficAllocation) }, 'No valid traffic allocation provided - will use equal distribution after platform detection'); } // Step 5: Build JSON Patch operations to update the existing rule const rulesetUpdateData = []; // CRITICAL FIX: Use platform to determine correct JSON patch paths // Feature Experimentation: /rules/{rule}/variations/ // Web Experimentation: /rules/{rule}/actions/0/changes/0/value/variations/ const isFeatureExperimentation = platform === 'feature' || projectTypeInfo.projectType === 'feature'; const variationsBasePath = isFeatureExperimentation ? `/rules/${existingRuleKey}/variations` : `/rules/${existingRuleKey}/actions/0/changes/0/value/variations`; // Handle platform differences: Web uses arrays, Feature uses objects let existingVariations = []; if (isFeatureExperimentation) { // Feature Experimentation: variations is an object keyed by variation key const variationsObject = existingAbTestRule.variations || {}; existingVariations = Object.values(variationsObject); } else { // Web Experimentation: variations is an array in actions existingVariations = existingAbTestRule.actions?.[0]?.changes?.[0]?.value?.variations || []; } this.logger.info({ platform, isFeatureExperimentation, variationsBasePath, existingRuleKey, existingVariationsCount: existingVariations.length, existingVariationsStructure: isFeatureExperimentation ? 'object→array' : 'array' }, 'PLATFORM DETECTION: Using platform-specific JSON patch paths'); // Add the newly created/existing variation to allFlagVariations if not already there if (createdVariation && !allFlagVariations.find(v => v.key === createdVariation.key)) { allFlagVariations.push(createdVariation); this.logger.info({ variationKey: createdVariation.key, totalVariations: allFlagVariations.length }, 'Added new variation to flag variations list'); } this.logger.info({ flagKey: flagResult.key, totalFlagVariations: allFlagVariations.length, allVariationKeys: allFlagVariations.map((v) => v.key), includesNewVariation: allFlagVariations.some(v => v.key === newVariation.key), newVariationKey: newVariation.key }, 'Retrieved all flag variations for update'); // Apply fallback logic if no traffic allocation was provided if (Object.keys(variationWeights).length === 0) { // Fall back to equal distribution const totalVariations = existingVariations.length + 1; const equalWeight = Math.floor(10000 / totalVariations);