@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
888 lines (887 loc) • 146 kB
JavaScript
/**
* 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);