mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-23 02:43:08 +00:00
- Normalize name→nodeName and id→nodeId for node-targeting operations in the Zod schema transform, so LLMs using natural field names no longer get "Node not found" errors - Replace hardcoded ALL_CONNECTION_TYPES with dynamic iteration so AI sub-nodes (ai_outputParser, ai_document, ai_textSplitter, etc.) are not flagged as disconnected during save - Add .catchall() to workflowConnectionSchema and extend connection reference validation to cover all connection types, not just main - Fix filterOperationsByFixes ID-vs-name mismatch: typeversion-upgrade operations now include nodeName alongside nodeId, and the filter checks both fields Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1311 lines
44 KiB
TypeScript
1311 lines
44 KiB
TypeScript
/**
|
|
* Workflow Auto-Fixer Service
|
|
*
|
|
* Automatically generates fix operations for common workflow validation errors.
|
|
* Converts validation results into diff operations that can be applied to fix the workflow.
|
|
*/
|
|
|
|
import crypto from 'crypto';
|
|
import { WorkflowValidationResult, VALID_CONNECTION_TYPES } from './workflow-validator';
|
|
import { ExpressionFormatIssue } from './expression-format-validator';
|
|
import { NodeSimilarityService } from './node-similarity-service';
|
|
import { NodeRepository } from '../database/node-repository';
|
|
import {
|
|
WorkflowDiffOperation,
|
|
UpdateNodeOperation,
|
|
ReplaceConnectionsOperation
|
|
} from '../types/workflow-diff';
|
|
import { WorkflowNode, Workflow } from '../types/n8n-api';
|
|
import { Logger } from '../utils/logger';
|
|
import { NodeVersionService } from './node-version-service';
|
|
import { BreakingChangeDetector } from './breaking-change-detector';
|
|
import { NodeMigrationService } from './node-migration-service';
|
|
import { PostUpdateValidator, PostUpdateGuidance } from './post-update-validator';
|
|
|
|
const logger = new Logger({ prefix: '[WorkflowAutoFixer]' });
|
|
|
|
export type FixConfidenceLevel = 'high' | 'medium' | 'low';
|
|
export type FixType =
|
|
| 'expression-format'
|
|
| 'typeversion-correction'
|
|
| 'error-output-config'
|
|
| 'node-type-correction'
|
|
| 'webhook-missing-path'
|
|
| 'typeversion-upgrade' // Proactive version upgrades
|
|
| 'version-migration' // Smart version migrations with breaking changes
|
|
| 'tool-variant-correction' // Fix base nodes used as AI tools when Tool variant exists
|
|
| 'connection-numeric-keys' // "0","1" keys → main[0], main[1]
|
|
| 'connection-invalid-type' // type:"0" → type:"main"
|
|
| 'connection-id-to-name' // node ID refs → node name refs
|
|
| 'connection-duplicate-removal' // Dedup identical connection entries
|
|
| 'connection-input-index'; // Out-of-bounds input index → clamped
|
|
|
|
export const CONNECTION_FIX_TYPES: FixType[] = [
|
|
'connection-numeric-keys',
|
|
'connection-invalid-type',
|
|
'connection-id-to-name',
|
|
'connection-duplicate-removal',
|
|
'connection-input-index'
|
|
];
|
|
|
|
export interface AutoFixConfig {
|
|
applyFixes: boolean;
|
|
fixTypes?: FixType[];
|
|
confidenceThreshold?: FixConfidenceLevel;
|
|
maxFixes?: number;
|
|
}
|
|
|
|
export interface FixOperation {
|
|
node: string;
|
|
field: string;
|
|
type: FixType;
|
|
before: any;
|
|
after: any;
|
|
confidence: FixConfidenceLevel;
|
|
description: string;
|
|
}
|
|
|
|
export interface AutoFixResult {
|
|
operations: WorkflowDiffOperation[];
|
|
fixes: FixOperation[];
|
|
summary: string;
|
|
stats: {
|
|
total: number;
|
|
byType: Record<FixType, number>;
|
|
byConfidence: Record<FixConfidenceLevel, number>;
|
|
};
|
|
postUpdateGuidance?: PostUpdateGuidance[]; // NEW: AI-friendly migration guidance
|
|
}
|
|
|
|
export interface NodeFormatIssue extends ExpressionFormatIssue {
|
|
nodeName: string;
|
|
nodeId: string;
|
|
}
|
|
|
|
/**
|
|
* Type guard to check if an issue has node information
|
|
*/
|
|
export function isNodeFormatIssue(issue: ExpressionFormatIssue): issue is NodeFormatIssue {
|
|
return 'nodeName' in issue && 'nodeId' in issue &&
|
|
typeof (issue as any).nodeName === 'string' &&
|
|
typeof (issue as any).nodeId === 'string';
|
|
}
|
|
|
|
/**
|
|
* Error with suggestions for node type issues
|
|
*/
|
|
export interface NodeTypeError {
|
|
type: 'error';
|
|
nodeId?: string;
|
|
nodeName?: string;
|
|
message: string;
|
|
suggestions?: Array<{
|
|
nodeType: string;
|
|
confidence: number;
|
|
reason: string;
|
|
}>;
|
|
}
|
|
|
|
export class WorkflowAutoFixer {
|
|
private readonly defaultConfig: AutoFixConfig = {
|
|
applyFixes: false,
|
|
confidenceThreshold: 'medium',
|
|
maxFixes: 50
|
|
};
|
|
private similarityService: NodeSimilarityService | null = null;
|
|
private versionService: NodeVersionService | null = null;
|
|
private breakingChangeDetector: BreakingChangeDetector | null = null;
|
|
private migrationService: NodeMigrationService | null = null;
|
|
private postUpdateValidator: PostUpdateValidator | null = null;
|
|
|
|
constructor(repository?: NodeRepository) {
|
|
if (repository) {
|
|
this.similarityService = new NodeSimilarityService(repository);
|
|
this.breakingChangeDetector = new BreakingChangeDetector(repository);
|
|
this.versionService = new NodeVersionService(repository, this.breakingChangeDetector);
|
|
this.migrationService = new NodeMigrationService(this.versionService, this.breakingChangeDetector);
|
|
this.postUpdateValidator = new PostUpdateValidator(this.versionService, this.breakingChangeDetector);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate fix operations from validation results
|
|
*/
|
|
async generateFixes(
|
|
workflow: Workflow,
|
|
validationResult: WorkflowValidationResult,
|
|
formatIssues: ExpressionFormatIssue[] = [],
|
|
config: Partial<AutoFixConfig> = {}
|
|
): Promise<AutoFixResult> {
|
|
const fullConfig = { ...this.defaultConfig, ...config };
|
|
const operations: WorkflowDiffOperation[] = [];
|
|
const fixes: FixOperation[] = [];
|
|
const postUpdateGuidance: PostUpdateGuidance[] = [];
|
|
|
|
// Create a map for quick node lookup
|
|
const nodeMap = new Map<string, WorkflowNode>();
|
|
workflow.nodes.forEach(node => {
|
|
nodeMap.set(node.name, node);
|
|
nodeMap.set(node.id, node);
|
|
});
|
|
|
|
// Process expression format issues (HIGH confidence)
|
|
if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('expression-format')) {
|
|
this.processExpressionFormatFixes(formatIssues, nodeMap, operations, fixes);
|
|
}
|
|
|
|
// Process typeVersion errors (MEDIUM confidence)
|
|
if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('typeversion-correction')) {
|
|
this.processTypeVersionFixes(validationResult, nodeMap, operations, fixes);
|
|
}
|
|
|
|
// Process error output configuration issues (MEDIUM confidence)
|
|
if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('error-output-config')) {
|
|
this.processErrorOutputFixes(validationResult, nodeMap, workflow, operations, fixes);
|
|
}
|
|
|
|
// Process node type corrections (HIGH confidence only)
|
|
if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('node-type-correction')) {
|
|
this.processNodeTypeFixes(validationResult, nodeMap, operations, fixes);
|
|
}
|
|
|
|
// Process webhook path fixes (HIGH confidence)
|
|
if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('webhook-missing-path')) {
|
|
this.processWebhookPathFixes(validationResult, nodeMap, operations, fixes);
|
|
}
|
|
|
|
// Process tool variant corrections (HIGH confidence)
|
|
if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('tool-variant-correction')) {
|
|
this.processToolVariantFixes(validationResult, nodeMap, workflow, operations, fixes);
|
|
}
|
|
|
|
// Process version upgrades (HIGH/MEDIUM confidence)
|
|
if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('typeversion-upgrade')) {
|
|
await this.processVersionUpgradeFixes(workflow, nodeMap, operations, fixes, postUpdateGuidance);
|
|
}
|
|
|
|
// NEW: Process version migrations with breaking changes (MEDIUM/LOW confidence)
|
|
if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('version-migration')) {
|
|
await this.processVersionMigrationFixes(workflow, nodeMap, operations, fixes, postUpdateGuidance);
|
|
}
|
|
|
|
// Process connection structure fixes (HIGH/MEDIUM confidence)
|
|
this.processConnectionFixes(workflow, validationResult, fullConfig, operations, fixes);
|
|
|
|
// Filter by confidence threshold
|
|
const filteredFixes = this.filterByConfidence(fixes, fullConfig.confidenceThreshold);
|
|
const filteredOperations = this.filterOperationsByFixes(operations, filteredFixes, fixes);
|
|
|
|
// Apply max fixes limit
|
|
const limitedFixes = filteredFixes.slice(0, fullConfig.maxFixes);
|
|
const limitedOperations = this.filterOperationsByFixes(filteredOperations, limitedFixes, filteredFixes);
|
|
|
|
// Generate summary
|
|
const stats = this.calculateStats(limitedFixes);
|
|
const summary = this.generateSummary(stats);
|
|
|
|
return {
|
|
operations: limitedOperations,
|
|
fixes: limitedFixes,
|
|
summary,
|
|
stats,
|
|
postUpdateGuidance: postUpdateGuidance.length > 0 ? postUpdateGuidance : undefined
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Process expression format fixes (missing = prefix)
|
|
*/
|
|
private processExpressionFormatFixes(
|
|
formatIssues: ExpressionFormatIssue[],
|
|
nodeMap: Map<string, WorkflowNode>,
|
|
operations: WorkflowDiffOperation[],
|
|
fixes: FixOperation[]
|
|
): void {
|
|
// Group fixes by node to create single update operation per node
|
|
const fixesByNode = new Map<string, ExpressionFormatIssue[]>();
|
|
|
|
for (const issue of formatIssues) {
|
|
// Process both errors and warnings for missing-prefix issues
|
|
if (issue.issueType === 'missing-prefix') {
|
|
// Use type guard to ensure we have node information
|
|
if (!isNodeFormatIssue(issue)) {
|
|
logger.warn('Expression format issue missing node information', {
|
|
fieldPath: issue.fieldPath,
|
|
issueType: issue.issueType
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const nodeName = issue.nodeName;
|
|
|
|
if (!fixesByNode.has(nodeName)) {
|
|
fixesByNode.set(nodeName, []);
|
|
}
|
|
fixesByNode.get(nodeName)!.push(issue);
|
|
}
|
|
}
|
|
|
|
// Create update operations for each node
|
|
for (const [nodeName, nodeIssues] of fixesByNode) {
|
|
const node = nodeMap.get(nodeName);
|
|
if (!node) continue;
|
|
|
|
const updatedParameters = JSON.parse(JSON.stringify(node.parameters || {}));
|
|
|
|
for (const issue of nodeIssues) {
|
|
// Apply the fix to parameters
|
|
// The fieldPath doesn't include node name, use as is
|
|
const fieldPath = issue.fieldPath.split('.');
|
|
this.setNestedValue(updatedParameters, fieldPath, issue.correctedValue);
|
|
|
|
fixes.push({
|
|
node: nodeName,
|
|
field: issue.fieldPath,
|
|
type: 'expression-format',
|
|
before: issue.currentValue,
|
|
after: issue.correctedValue,
|
|
confidence: 'high',
|
|
description: issue.explanation
|
|
});
|
|
}
|
|
|
|
// Create update operation
|
|
const operation: UpdateNodeOperation = {
|
|
type: 'updateNode',
|
|
nodeId: nodeName, // Can be name or ID
|
|
updates: {
|
|
parameters: updatedParameters
|
|
}
|
|
};
|
|
operations.push(operation);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process typeVersion fixes
|
|
*/
|
|
private processTypeVersionFixes(
|
|
validationResult: WorkflowValidationResult,
|
|
nodeMap: Map<string, WorkflowNode>,
|
|
operations: WorkflowDiffOperation[],
|
|
fixes: FixOperation[]
|
|
): void {
|
|
for (const error of validationResult.errors) {
|
|
if (error.message.includes('typeVersion') && error.message.includes('exceeds maximum')) {
|
|
// Extract version info from error message
|
|
const versionMatch = error.message.match(/typeVersion (\d+(?:\.\d+)?) exceeds maximum supported version (\d+(?:\.\d+)?)/);
|
|
if (versionMatch) {
|
|
const currentVersion = parseFloat(versionMatch[1]);
|
|
const maxVersion = parseFloat(versionMatch[2]);
|
|
const nodeName = error.nodeName || error.nodeId;
|
|
|
|
if (!nodeName) continue;
|
|
|
|
const node = nodeMap.get(nodeName);
|
|
if (!node) continue;
|
|
|
|
fixes.push({
|
|
node: nodeName,
|
|
field: 'typeVersion',
|
|
type: 'typeversion-correction',
|
|
before: currentVersion,
|
|
after: maxVersion,
|
|
confidence: 'medium',
|
|
description: `Corrected typeVersion from ${currentVersion} to maximum supported ${maxVersion}`
|
|
});
|
|
|
|
const operation: UpdateNodeOperation = {
|
|
type: 'updateNode',
|
|
nodeId: nodeName,
|
|
updates: {
|
|
typeVersion: maxVersion
|
|
}
|
|
};
|
|
operations.push(operation);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process error output configuration fixes
|
|
*/
|
|
private processErrorOutputFixes(
|
|
validationResult: WorkflowValidationResult,
|
|
nodeMap: Map<string, WorkflowNode>,
|
|
workflow: Workflow,
|
|
operations: WorkflowDiffOperation[],
|
|
fixes: FixOperation[]
|
|
): void {
|
|
for (const error of validationResult.errors) {
|
|
if (error.message.includes('onError: \'continueErrorOutput\'') &&
|
|
error.message.includes('no error output connections')) {
|
|
const nodeName = error.nodeName || error.nodeId;
|
|
if (!nodeName) continue;
|
|
|
|
const node = nodeMap.get(nodeName);
|
|
if (!node) continue;
|
|
|
|
// Remove the conflicting onError setting
|
|
fixes.push({
|
|
node: nodeName,
|
|
field: 'onError',
|
|
type: 'error-output-config',
|
|
before: 'continueErrorOutput',
|
|
after: undefined,
|
|
confidence: 'medium',
|
|
description: 'Removed onError setting due to missing error output connections'
|
|
});
|
|
|
|
const operation: UpdateNodeOperation = {
|
|
type: 'updateNode',
|
|
nodeId: nodeName,
|
|
updates: {
|
|
onError: undefined // This will remove the property
|
|
}
|
|
};
|
|
operations.push(operation);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process node type corrections for unknown nodes
|
|
*/
|
|
private processNodeTypeFixes(
|
|
validationResult: WorkflowValidationResult,
|
|
nodeMap: Map<string, WorkflowNode>,
|
|
operations: WorkflowDiffOperation[],
|
|
fixes: FixOperation[]
|
|
): void {
|
|
// Only process if we have the similarity service
|
|
if (!this.similarityService) {
|
|
return;
|
|
}
|
|
|
|
for (const error of validationResult.errors) {
|
|
// Type-safe check for unknown node type errors with suggestions
|
|
const nodeError = error as NodeTypeError;
|
|
|
|
if (error.message?.includes('Unknown node type:') && nodeError.suggestions) {
|
|
// Only auto-fix if we have a high-confidence suggestion (>= 0.9)
|
|
const highConfidenceSuggestion = nodeError.suggestions.find(s => s.confidence >= 0.9);
|
|
|
|
if (highConfidenceSuggestion && nodeError.nodeId) {
|
|
const node = nodeMap.get(nodeError.nodeId) || nodeMap.get(nodeError.nodeName || '');
|
|
|
|
if (node) {
|
|
fixes.push({
|
|
node: node.name,
|
|
field: 'type',
|
|
type: 'node-type-correction',
|
|
before: node.type,
|
|
after: highConfidenceSuggestion.nodeType,
|
|
confidence: 'high',
|
|
description: `Fix node type: "${node.type}" → "${highConfidenceSuggestion.nodeType}" (${highConfidenceSuggestion.reason})`
|
|
});
|
|
|
|
const operation: UpdateNodeOperation = {
|
|
type: 'updateNode',
|
|
nodeId: node.name,
|
|
updates: {
|
|
type: highConfidenceSuggestion.nodeType
|
|
}
|
|
};
|
|
operations.push(operation);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process webhook path fixes for webhook nodes missing path parameter
|
|
*/
|
|
private processWebhookPathFixes(
|
|
validationResult: WorkflowValidationResult,
|
|
nodeMap: Map<string, WorkflowNode>,
|
|
operations: WorkflowDiffOperation[],
|
|
fixes: FixOperation[]
|
|
): void {
|
|
for (const error of validationResult.errors) {
|
|
// Check for webhook path required error
|
|
if (error.message === 'Webhook path is required') {
|
|
const nodeName = error.nodeName || error.nodeId;
|
|
if (!nodeName) continue;
|
|
|
|
const node = nodeMap.get(nodeName);
|
|
if (!node) continue;
|
|
|
|
// Only fix webhook nodes
|
|
if (!node.type?.includes('webhook')) continue;
|
|
|
|
// Generate a unique UUID for both path and webhookId
|
|
const webhookId = crypto.randomUUID();
|
|
|
|
// Check if we need to update typeVersion
|
|
const currentTypeVersion = node.typeVersion || 1;
|
|
const needsVersionUpdate = currentTypeVersion < 2.1;
|
|
|
|
fixes.push({
|
|
node: nodeName,
|
|
field: 'path',
|
|
type: 'webhook-missing-path',
|
|
before: undefined,
|
|
after: webhookId,
|
|
confidence: 'high',
|
|
description: needsVersionUpdate
|
|
? `Generated webhook path and ID: ${webhookId} (also updating typeVersion to 2.1)`
|
|
: `Generated webhook path and ID: ${webhookId}`
|
|
});
|
|
|
|
// Create update operation with both path and webhookId
|
|
// The updates object uses dot notation for nested properties
|
|
const updates: Record<string, any> = {
|
|
'parameters.path': webhookId,
|
|
'webhookId': webhookId
|
|
};
|
|
|
|
// Only update typeVersion if it's older than 2.1
|
|
if (needsVersionUpdate) {
|
|
updates['typeVersion'] = 2.1;
|
|
}
|
|
|
|
const operation: UpdateNodeOperation = {
|
|
type: 'updateNode',
|
|
nodeId: nodeName,
|
|
updates
|
|
};
|
|
operations.push(operation);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process tool variant corrections for base nodes incorrectly used as AI tools.
|
|
*
|
|
* When a base node (e.g., n8n-nodes-base.supabase) is connected via ai_tool output
|
|
* but has a Tool variant available (e.g., n8n-nodes-base.supabaseTool), this fix
|
|
* replaces the node type with the correct Tool variant.
|
|
*
|
|
* @param validationResult - Validation result containing errors to process
|
|
* @param nodeMap - Map of node names/IDs to node objects
|
|
* @param _workflow - Workflow object (unused, kept for API consistency with other fix methods)
|
|
* @param operations - Array to push generated diff operations to
|
|
* @param fixes - Array to push generated fix records to
|
|
*/
|
|
private processToolVariantFixes(
|
|
validationResult: WorkflowValidationResult,
|
|
nodeMap: Map<string, WorkflowNode>,
|
|
_workflow: Workflow,
|
|
operations: WorkflowDiffOperation[],
|
|
fixes: FixOperation[]
|
|
): void {
|
|
for (const error of validationResult.errors) {
|
|
// Check for errors with the WRONG_NODE_TYPE_FOR_AI_TOOL code
|
|
// ValidationIssue interface includes optional code and fix properties
|
|
if (error.code !== 'WRONG_NODE_TYPE_FOR_AI_TOOL' || !error.fix) {
|
|
continue;
|
|
}
|
|
|
|
const fix = error.fix;
|
|
if (fix.type !== 'tool-variant-correction') {
|
|
continue;
|
|
}
|
|
|
|
const nodeName = error.nodeName || error.nodeId;
|
|
if (!nodeName) continue;
|
|
|
|
const node = nodeMap.get(nodeName);
|
|
if (!node) continue;
|
|
|
|
// Create the fix record
|
|
fixes.push({
|
|
node: nodeName,
|
|
field: 'type',
|
|
type: 'tool-variant-correction',
|
|
before: fix.currentType,
|
|
after: fix.suggestedType,
|
|
confidence: 'high', // This is a direct match - we know exactly which type to use
|
|
description: fix.description || `Replace "${fix.currentType}" with Tool variant "${fix.suggestedType}"`
|
|
});
|
|
|
|
// Create the update operation
|
|
const operation: UpdateNodeOperation = {
|
|
type: 'updateNode',
|
|
nodeId: nodeName,
|
|
updates: {
|
|
type: fix.suggestedType
|
|
}
|
|
};
|
|
operations.push(operation);
|
|
|
|
logger.info(`Generated tool variant correction for ${nodeName}: ${fix.currentType} → ${fix.suggestedType}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a nested value in an object using a path array
|
|
* Includes validation to prevent silent failures
|
|
*/
|
|
private setNestedValue(obj: any, path: string[], value: any): void {
|
|
if (!obj || typeof obj !== 'object') {
|
|
throw new Error('Cannot set value on non-object');
|
|
}
|
|
|
|
if (path.length === 0) {
|
|
throw new Error('Cannot set value with empty path');
|
|
}
|
|
|
|
try {
|
|
let current = obj;
|
|
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
const key = path[i];
|
|
|
|
// Handle array indices
|
|
if (key.includes('[')) {
|
|
const matches = key.match(/^([^[]+)\[(\d+)\]$/);
|
|
if (!matches) {
|
|
throw new Error(`Invalid array notation: ${key}`);
|
|
}
|
|
|
|
const [, arrayKey, indexStr] = matches;
|
|
const index = parseInt(indexStr, 10);
|
|
|
|
if (isNaN(index) || index < 0) {
|
|
throw new Error(`Invalid array index: ${indexStr}`);
|
|
}
|
|
|
|
if (!current[arrayKey]) {
|
|
current[arrayKey] = [];
|
|
}
|
|
|
|
if (!Array.isArray(current[arrayKey])) {
|
|
throw new Error(`Expected array at ${arrayKey}, got ${typeof current[arrayKey]}`);
|
|
}
|
|
|
|
while (current[arrayKey].length <= index) {
|
|
current[arrayKey].push({});
|
|
}
|
|
|
|
current = current[arrayKey][index];
|
|
} else {
|
|
if (current[key] === null || current[key] === undefined) {
|
|
current[key] = {};
|
|
}
|
|
|
|
if (typeof current[key] !== 'object' || Array.isArray(current[key])) {
|
|
throw new Error(`Cannot traverse through ${typeof current[key]} at ${key}`);
|
|
}
|
|
|
|
current = current[key];
|
|
}
|
|
}
|
|
|
|
// Set the final value
|
|
const lastKey = path[path.length - 1];
|
|
|
|
if (lastKey.includes('[')) {
|
|
const matches = lastKey.match(/^([^[]+)\[(\d+)\]$/);
|
|
if (!matches) {
|
|
throw new Error(`Invalid array notation: ${lastKey}`);
|
|
}
|
|
|
|
const [, arrayKey, indexStr] = matches;
|
|
const index = parseInt(indexStr, 10);
|
|
|
|
if (isNaN(index) || index < 0) {
|
|
throw new Error(`Invalid array index: ${indexStr}`);
|
|
}
|
|
|
|
if (!current[arrayKey]) {
|
|
current[arrayKey] = [];
|
|
}
|
|
|
|
if (!Array.isArray(current[arrayKey])) {
|
|
throw new Error(`Expected array at ${arrayKey}, got ${typeof current[arrayKey]}`);
|
|
}
|
|
|
|
while (current[arrayKey].length <= index) {
|
|
current[arrayKey].push(null);
|
|
}
|
|
|
|
current[arrayKey][index] = value;
|
|
} else {
|
|
current[lastKey] = value;
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to set nested value', {
|
|
path: path.join('.'),
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter fixes by confidence level
|
|
*/
|
|
private filterByConfidence(
|
|
fixes: FixOperation[],
|
|
threshold?: FixConfidenceLevel
|
|
): FixOperation[] {
|
|
if (!threshold) return fixes;
|
|
|
|
const levels: FixConfidenceLevel[] = ['high', 'medium', 'low'];
|
|
const thresholdIndex = levels.indexOf(threshold);
|
|
|
|
return fixes.filter(fix => {
|
|
const fixIndex = levels.indexOf(fix.confidence);
|
|
return fixIndex <= thresholdIndex;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter operations to match filtered fixes
|
|
*/
|
|
private filterOperationsByFixes(
|
|
operations: WorkflowDiffOperation[],
|
|
filteredFixes: FixOperation[],
|
|
allFixes: FixOperation[]
|
|
): WorkflowDiffOperation[] {
|
|
// fixedNodes contains node names (FixOperation.node = node.name)
|
|
const fixedNodes = new Set(filteredFixes.map(f => f.node));
|
|
const hasConnectionFixes = filteredFixes.some(f => CONNECTION_FIX_TYPES.includes(f.type));
|
|
return operations.filter(op => {
|
|
if (op.type === 'updateNode') {
|
|
// Check both nodeName and nodeId — operations may use either
|
|
return fixedNodes.has(op.nodeName || '') || fixedNodes.has(op.nodeId || '');
|
|
}
|
|
if (op.type === 'replaceConnections') {
|
|
return hasConnectionFixes;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculate statistics about fixes
|
|
*/
|
|
private calculateStats(fixes: FixOperation[]): AutoFixResult['stats'] {
|
|
const stats: AutoFixResult['stats'] = {
|
|
total: fixes.length,
|
|
byType: {
|
|
'expression-format': 0,
|
|
'typeversion-correction': 0,
|
|
'error-output-config': 0,
|
|
'node-type-correction': 0,
|
|
'webhook-missing-path': 0,
|
|
'typeversion-upgrade': 0,
|
|
'version-migration': 0,
|
|
'tool-variant-correction': 0,
|
|
'connection-numeric-keys': 0,
|
|
'connection-invalid-type': 0,
|
|
'connection-id-to-name': 0,
|
|
'connection-duplicate-removal': 0,
|
|
'connection-input-index': 0
|
|
},
|
|
byConfidence: {
|
|
'high': 0,
|
|
'medium': 0,
|
|
'low': 0
|
|
}
|
|
};
|
|
|
|
for (const fix of fixes) {
|
|
stats.byType[fix.type]++;
|
|
stats.byConfidence[fix.confidence]++;
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Generate a human-readable summary
|
|
*/
|
|
private generateSummary(stats: AutoFixResult['stats']): string {
|
|
if (stats.total === 0) {
|
|
return 'No fixes available';
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
|
|
if (stats.byType['expression-format'] > 0) {
|
|
parts.push(`${stats.byType['expression-format']} expression format ${stats.byType['expression-format'] === 1 ? 'error' : 'errors'}`);
|
|
}
|
|
if (stats.byType['typeversion-correction'] > 0) {
|
|
parts.push(`${stats.byType['typeversion-correction']} version ${stats.byType['typeversion-correction'] === 1 ? 'issue' : 'issues'}`);
|
|
}
|
|
if (stats.byType['error-output-config'] > 0) {
|
|
parts.push(`${stats.byType['error-output-config']} error output ${stats.byType['error-output-config'] === 1 ? 'configuration' : 'configurations'}`);
|
|
}
|
|
if (stats.byType['node-type-correction'] > 0) {
|
|
parts.push(`${stats.byType['node-type-correction']} node type ${stats.byType['node-type-correction'] === 1 ? 'correction' : 'corrections'}`);
|
|
}
|
|
if (stats.byType['webhook-missing-path'] > 0) {
|
|
parts.push(`${stats.byType['webhook-missing-path']} webhook ${stats.byType['webhook-missing-path'] === 1 ? 'path' : 'paths'}`);
|
|
}
|
|
|
|
if (stats.byType['typeversion-upgrade'] > 0) {
|
|
parts.push(`${stats.byType['typeversion-upgrade']} version ${stats.byType['typeversion-upgrade'] === 1 ? 'upgrade' : 'upgrades'}`);
|
|
}
|
|
if (stats.byType['version-migration'] > 0) {
|
|
parts.push(`${stats.byType['version-migration']} version ${stats.byType['version-migration'] === 1 ? 'migration' : 'migrations'}`);
|
|
}
|
|
if (stats.byType['tool-variant-correction'] > 0) {
|
|
parts.push(`${stats.byType['tool-variant-correction']} tool variant ${stats.byType['tool-variant-correction'] === 1 ? 'correction' : 'corrections'}`);
|
|
}
|
|
|
|
const connectionIssueCount =
|
|
(stats.byType['connection-numeric-keys'] || 0) +
|
|
(stats.byType['connection-invalid-type'] || 0) +
|
|
(stats.byType['connection-id-to-name'] || 0) +
|
|
(stats.byType['connection-duplicate-removal'] || 0) +
|
|
(stats.byType['connection-input-index'] || 0);
|
|
if (connectionIssueCount > 0) {
|
|
parts.push(`${connectionIssueCount} connection ${connectionIssueCount === 1 ? 'issue' : 'issues'}`);
|
|
}
|
|
|
|
if (parts.length === 0) {
|
|
return `Fixed ${stats.total} ${stats.total === 1 ? 'issue' : 'issues'}`;
|
|
}
|
|
|
|
return `Fixed ${parts.join(', ')}`;
|
|
}
|
|
|
|
/**
|
|
* Process connection structure fixes.
|
|
* Deep-clones workflow.connections, applies fixes in order:
|
|
* numeric keys → ID-to-name → invalid type → input index → dedup
|
|
* Emits a single ReplaceConnectionsOperation if any corrections were made.
|
|
*/
|
|
private processConnectionFixes(
|
|
workflow: Workflow,
|
|
validationResult: WorkflowValidationResult,
|
|
config: AutoFixConfig,
|
|
operations: WorkflowDiffOperation[],
|
|
fixes: FixOperation[]
|
|
): void {
|
|
if (!workflow.connections || Object.keys(workflow.connections).length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Build lookup maps
|
|
const idToNameMap = new Map<string, string>();
|
|
const nameSet = new Set<string>();
|
|
for (const node of workflow.nodes) {
|
|
idToNameMap.set(node.id, node.name);
|
|
nameSet.add(node.name);
|
|
}
|
|
|
|
// Deep-clone connections
|
|
const conn: any = JSON.parse(JSON.stringify(workflow.connections));
|
|
let anyFixed = false;
|
|
|
|
// 1. Fix numeric source keys ("0" → main[0])
|
|
if (!config.fixTypes || config.fixTypes.includes('connection-numeric-keys')) {
|
|
const numericKeyResult = this.fixNumericKeys(conn);
|
|
if (numericKeyResult.length > 0) {
|
|
fixes.push(...numericKeyResult);
|
|
anyFixed = true;
|
|
}
|
|
}
|
|
|
|
// 2. Fix ID-to-name references (source keys and .node values)
|
|
if (!config.fixTypes || config.fixTypes.includes('connection-id-to-name')) {
|
|
const idToNameResult = this.fixIdToName(conn, idToNameMap, nameSet);
|
|
if (idToNameResult.length > 0) {
|
|
fixes.push(...idToNameResult);
|
|
anyFixed = true;
|
|
}
|
|
}
|
|
|
|
// 3. Fix invalid connection types
|
|
if (!config.fixTypes || config.fixTypes.includes('connection-invalid-type')) {
|
|
const invalidTypeResult = this.fixInvalidTypes(conn);
|
|
if (invalidTypeResult.length > 0) {
|
|
fixes.push(...invalidTypeResult);
|
|
anyFixed = true;
|
|
}
|
|
}
|
|
|
|
// 4. Fix out-of-bounds input indices
|
|
if (!config.fixTypes || config.fixTypes.includes('connection-input-index')) {
|
|
const inputIndexResult = this.fixInputIndices(conn, validationResult, workflow);
|
|
if (inputIndexResult.length > 0) {
|
|
fixes.push(...inputIndexResult);
|
|
anyFixed = true;
|
|
}
|
|
}
|
|
|
|
// 5. Dedup identical connection entries
|
|
if (!config.fixTypes || config.fixTypes.includes('connection-duplicate-removal')) {
|
|
const dedupResult = this.fixDuplicateConnections(conn);
|
|
if (dedupResult.length > 0) {
|
|
fixes.push(...dedupResult);
|
|
anyFixed = true;
|
|
}
|
|
}
|
|
|
|
if (anyFixed) {
|
|
const op: ReplaceConnectionsOperation = {
|
|
type: 'replaceConnections',
|
|
connections: conn
|
|
};
|
|
operations.push(op);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fix numeric connection output keys ("0", "1" → main[0], main[1])
|
|
*/
|
|
private fixNumericKeys(conn: any): FixOperation[] {
|
|
const fixes: FixOperation[] = [];
|
|
const sourceNodes = Object.keys(conn);
|
|
|
|
for (const sourceName of sourceNodes) {
|
|
const nodeConn = conn[sourceName];
|
|
const numericKeys = Object.keys(nodeConn).filter(k => /^\d+$/.test(k));
|
|
|
|
if (numericKeys.length === 0) continue;
|
|
|
|
// Ensure main array exists
|
|
if (!nodeConn['main']) {
|
|
nodeConn['main'] = [];
|
|
}
|
|
|
|
for (const numKey of numericKeys) {
|
|
const index = parseInt(numKey, 10);
|
|
const entries = nodeConn[numKey];
|
|
|
|
// Extend main array if needed (fill gaps with empty arrays)
|
|
while (nodeConn['main'].length <= index) {
|
|
nodeConn['main'].push([]);
|
|
}
|
|
|
|
// Merge entries into main[index]
|
|
const hadExisting = nodeConn['main'][index] && nodeConn['main'][index].length > 0;
|
|
if (Array.isArray(entries)) {
|
|
for (const outputGroup of entries) {
|
|
if (Array.isArray(outputGroup)) {
|
|
nodeConn['main'][index] = [
|
|
...nodeConn['main'][index],
|
|
...outputGroup
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hadExisting) {
|
|
logger.warn(`Merged numeric key "${numKey}" into existing main[${index}] on node "${sourceName}" - dedup pass will clean exact duplicates`);
|
|
}
|
|
|
|
fixes.push({
|
|
node: sourceName,
|
|
field: `connections.${sourceName}.${numKey}`,
|
|
type: 'connection-numeric-keys',
|
|
before: numKey,
|
|
after: `main[${index}]`,
|
|
confidence: hadExisting ? 'medium' : 'high',
|
|
description: hadExisting
|
|
? `Merged numeric connection key "${numKey}" into existing main[${index}] on node "${sourceName}"`
|
|
: `Converted numeric connection key "${numKey}" to main[${index}] on node "${sourceName}"`
|
|
});
|
|
|
|
delete nodeConn[numKey];
|
|
}
|
|
}
|
|
|
|
return fixes;
|
|
}
|
|
|
|
/**
|
|
* Fix node ID references in connections (replace IDs with names)
|
|
*/
|
|
private fixIdToName(
|
|
conn: any,
|
|
idToNameMap: Map<string, string>,
|
|
nameSet: Set<string>
|
|
): FixOperation[] {
|
|
const fixes: FixOperation[] = [];
|
|
|
|
// Build rename plan for source keys, then check for collisions
|
|
const renames: Array<{ oldKey: string; newKey: string }> = [];
|
|
const sourceKeys = Object.keys(conn);
|
|
for (const sourceKey of sourceKeys) {
|
|
if (idToNameMap.has(sourceKey) && !nameSet.has(sourceKey)) {
|
|
renames.push({ oldKey: sourceKey, newKey: idToNameMap.get(sourceKey)! });
|
|
}
|
|
}
|
|
|
|
// Check for collisions among renames (two IDs mapping to the same name)
|
|
const newKeyCount = new Map<string, number>();
|
|
for (const r of renames) {
|
|
newKeyCount.set(r.newKey, (newKeyCount.get(r.newKey) || 0) + 1);
|
|
}
|
|
const safeRenames = renames.filter(r => {
|
|
if ((newKeyCount.get(r.newKey) || 0) > 1) {
|
|
logger.warn(`Skipping ambiguous ID-to-name rename: "${r.oldKey}" → "${r.newKey}" (multiple IDs map to same name)`);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
for (const { oldKey, newKey } of safeRenames) {
|
|
conn[newKey] = conn[oldKey];
|
|
delete conn[oldKey];
|
|
fixes.push({
|
|
node: newKey,
|
|
field: `connections.sourceKey`,
|
|
type: 'connection-id-to-name',
|
|
before: oldKey,
|
|
after: newKey,
|
|
confidence: 'high',
|
|
description: `Replaced node ID "${oldKey}" with name "${newKey}" as connection source key`
|
|
});
|
|
}
|
|
|
|
// Fix .node values that are node IDs
|
|
for (const sourceName of Object.keys(conn)) {
|
|
const nodeConn = conn[sourceName];
|
|
for (const outputKey of Object.keys(nodeConn)) {
|
|
const outputs = nodeConn[outputKey];
|
|
if (!Array.isArray(outputs)) continue;
|
|
for (const outputGroup of outputs) {
|
|
if (!Array.isArray(outputGroup)) continue;
|
|
for (const entry of outputGroup) {
|
|
if (entry && entry.node && idToNameMap.has(entry.node) && !nameSet.has(entry.node)) {
|
|
const oldNode = entry.node;
|
|
const newNode = idToNameMap.get(entry.node)!;
|
|
entry.node = newNode;
|
|
fixes.push({
|
|
node: sourceName,
|
|
field: `connections.${sourceName}.${outputKey}[].node`,
|
|
type: 'connection-id-to-name',
|
|
before: oldNode,
|
|
after: newNode,
|
|
confidence: 'high',
|
|
description: `Replaced target node ID "${oldNode}" with name "${newNode}" in connection from "${sourceName}"`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return fixes;
|
|
}
|
|
|
|
/**
|
|
* Fix invalid connection types in entries (e.g., type:"0" → type:"main")
|
|
*/
|
|
private fixInvalidTypes(conn: any): FixOperation[] {
|
|
const fixes: FixOperation[] = [];
|
|
|
|
for (const sourceName of Object.keys(conn)) {
|
|
const nodeConn = conn[sourceName];
|
|
for (const outputKey of Object.keys(nodeConn)) {
|
|
const outputs = nodeConn[outputKey];
|
|
if (!Array.isArray(outputs)) continue;
|
|
for (const outputGroup of outputs) {
|
|
if (!Array.isArray(outputGroup)) continue;
|
|
for (const entry of outputGroup) {
|
|
if (entry && entry.type && !VALID_CONNECTION_TYPES.has(entry.type)) {
|
|
const oldType = entry.type;
|
|
// Use the parent output key if it's valid, otherwise default to "main"
|
|
const newType = VALID_CONNECTION_TYPES.has(outputKey) ? outputKey : 'main';
|
|
entry.type = newType;
|
|
fixes.push({
|
|
node: sourceName,
|
|
field: `connections.${sourceName}.${outputKey}[].type`,
|
|
type: 'connection-invalid-type',
|
|
before: oldType,
|
|
after: newType,
|
|
confidence: 'high',
|
|
description: `Fixed invalid connection type "${oldType}" → "${newType}" in connection from "${sourceName}" to "${entry.node}"`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return fixes;
|
|
}
|
|
|
|
/**
|
|
* Fix out-of-bounds input indices (clamp to valid range)
|
|
*/
|
|
private fixInputIndices(
|
|
conn: any,
|
|
validationResult: WorkflowValidationResult,
|
|
workflow: Workflow
|
|
): FixOperation[] {
|
|
const fixes: FixOperation[] = [];
|
|
|
|
// Parse INPUT_INDEX_OUT_OF_BOUNDS errors from validation
|
|
for (const error of validationResult.errors) {
|
|
if (error.code !== 'INPUT_INDEX_OUT_OF_BOUNDS') continue;
|
|
|
|
const targetNodeName = error.nodeName;
|
|
if (!targetNodeName) continue;
|
|
|
|
// Extract the bad index and input count from the error message
|
|
const match = error.message.match(/Input index (\d+).*?has (\d+) main input/);
|
|
if (!match) {
|
|
logger.warn(`Could not parse INPUT_INDEX_OUT_OF_BOUNDS error for node "${targetNodeName}": ${error.message}`);
|
|
continue;
|
|
}
|
|
|
|
const badIndex = parseInt(match[1], 10);
|
|
const inputCount = parseInt(match[2], 10);
|
|
|
|
// For multi-input nodes, clamp to max valid index; for single-input, reset to 0
|
|
const clampedIndex = inputCount > 1 ? Math.min(badIndex, inputCount - 1) : 0;
|
|
|
|
// Find and fix the bad index in connections
|
|
for (const sourceName of Object.keys(conn)) {
|
|
const nodeConn = conn[sourceName];
|
|
for (const outputKey of Object.keys(nodeConn)) {
|
|
const outputs = nodeConn[outputKey];
|
|
if (!Array.isArray(outputs)) continue;
|
|
for (const outputGroup of outputs) {
|
|
if (!Array.isArray(outputGroup)) continue;
|
|
for (const entry of outputGroup) {
|
|
if (entry && entry.node === targetNodeName && entry.index === badIndex) {
|
|
entry.index = clampedIndex;
|
|
fixes.push({
|
|
node: sourceName,
|
|
field: `connections.${sourceName}.${outputKey}[].index`,
|
|
type: 'connection-input-index',
|
|
before: badIndex,
|
|
after: clampedIndex,
|
|
confidence: 'medium',
|
|
description: `Clamped input index ${badIndex} → ${clampedIndex} for target node "${targetNodeName}" (has ${inputCount} input${inputCount === 1 ? '' : 's'})`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return fixes;
|
|
}
|
|
|
|
/**
|
|
* Remove duplicate connection entries (same node, type, index)
|
|
*/
|
|
private fixDuplicateConnections(conn: any): FixOperation[] {
|
|
const fixes: FixOperation[] = [];
|
|
|
|
for (const sourceName of Object.keys(conn)) {
|
|
const nodeConn = conn[sourceName];
|
|
for (const outputKey of Object.keys(nodeConn)) {
|
|
const outputs = nodeConn[outputKey];
|
|
if (!Array.isArray(outputs)) continue;
|
|
for (let i = 0; i < outputs.length; i++) {
|
|
const outputGroup = outputs[i];
|
|
if (!Array.isArray(outputGroup)) continue;
|
|
|
|
const seen = new Set<string>();
|
|
const deduped: any[] = [];
|
|
|
|
for (const entry of outputGroup) {
|
|
const key = JSON.stringify({ node: entry.node, type: entry.type, index: entry.index });
|
|
if (seen.has(key)) {
|
|
fixes.push({
|
|
node: sourceName,
|
|
field: `connections.${sourceName}.${outputKey}[${i}]`,
|
|
type: 'connection-duplicate-removal',
|
|
before: entry,
|
|
after: null,
|
|
confidence: 'high',
|
|
description: `Removed duplicate connection from "${sourceName}" to "${entry.node}" (type: ${entry.type}, index: ${entry.index})`
|
|
});
|
|
} else {
|
|
seen.add(key);
|
|
deduped.push(entry);
|
|
}
|
|
}
|
|
|
|
outputs[i] = deduped;
|
|
}
|
|
}
|
|
}
|
|
|
|
return fixes;
|
|
}
|
|
|
|
/**
|
|
* Process version upgrade fixes (proactive upgrades to latest versions)
|
|
* HIGH confidence for non-breaking upgrades, MEDIUM for upgrades with auto-migratable changes
|
|
*/
|
|
private async processVersionUpgradeFixes(
|
|
workflow: Workflow,
|
|
nodeMap: Map<string, WorkflowNode>,
|
|
operations: WorkflowDiffOperation[],
|
|
fixes: FixOperation[],
|
|
postUpdateGuidance: PostUpdateGuidance[]
|
|
): Promise<void> {
|
|
if (!this.versionService || !this.migrationService || !this.postUpdateValidator) {
|
|
logger.warn('Version services not initialized. Skipping version upgrade fixes.');
|
|
return;
|
|
}
|
|
|
|
for (const node of workflow.nodes) {
|
|
if (!node.typeVersion || !node.type) continue;
|
|
|
|
const currentVersion = node.typeVersion.toString();
|
|
const analysis = this.versionService.analyzeVersion(node.type, currentVersion);
|
|
|
|
// Only upgrade if outdated and recommended
|
|
if (!analysis.isOutdated || !analysis.recommendUpgrade) continue;
|
|
|
|
// Skip if confidence is too low
|
|
if (analysis.confidence === 'LOW') continue;
|
|
|
|
const latestVersion = analysis.latestVersion;
|
|
|
|
// Attempt migration
|
|
try {
|
|
const migrationResult = await this.migrationService.migrateNode(
|
|
node,
|
|
currentVersion,
|
|
latestVersion
|
|
);
|
|
|
|
// Create fix operation
|
|
fixes.push({
|
|
node: node.name,
|
|
field: 'typeVersion',
|
|
type: 'typeversion-upgrade',
|
|
before: currentVersion,
|
|
after: latestVersion,
|
|
confidence: analysis.hasBreakingChanges ? 'medium' : 'high',
|
|
description: `Upgrade ${node.name} from v${currentVersion} to v${latestVersion}. ${analysis.reason}`
|
|
});
|
|
|
|
// Create update operation — both nodeId and nodeName needed for fix filtering
|
|
const operation: UpdateNodeOperation = {
|
|
type: 'updateNode',
|
|
nodeId: node.id,
|
|
nodeName: node.name,
|
|
updates: {
|
|
typeVersion: parseFloat(latestVersion),
|
|
parameters: migrationResult.updatedNode.parameters,
|
|
...(migrationResult.updatedNode.webhookId && { webhookId: migrationResult.updatedNode.webhookId })
|
|
}
|
|
};
|
|
operations.push(operation);
|
|
|
|
// Generate post-update guidance
|
|
const guidance = await this.postUpdateValidator.generateGuidance(
|
|
node.id,
|
|
node.name,
|
|
node.type,
|
|
currentVersion,
|
|
latestVersion,
|
|
migrationResult
|
|
);
|
|
|
|
postUpdateGuidance.push(guidance);
|
|
|
|
logger.info(`Generated version upgrade fix for ${node.name}: ${currentVersion} → ${latestVersion}`, {
|
|
appliedMigrations: migrationResult.appliedMigrations.length,
|
|
remainingIssues: migrationResult.remainingIssues.length
|
|
});
|
|
} catch (error) {
|
|
logger.error(`Failed to process version upgrade for ${node.name}`, { error });
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process version migration fixes (handle breaking changes with smart migrations)
|
|
* MEDIUM/LOW confidence for migrations requiring manual intervention
|
|
*/
|
|
private async processVersionMigrationFixes(
|
|
workflow: Workflow,
|
|
nodeMap: Map<string, WorkflowNode>,
|
|
operations: WorkflowDiffOperation[],
|
|
fixes: FixOperation[],
|
|
postUpdateGuidance: PostUpdateGuidance[]
|
|
): Promise<void> {
|
|
// This method handles migrations that weren't covered by typeversion-upgrade
|
|
// Focuses on nodes with complex breaking changes that need manual review
|
|
|
|
if (!this.versionService || !this.breakingChangeDetector || !this.postUpdateValidator) {
|
|
logger.warn('Version services not initialized. Skipping version migration fixes.');
|
|
return;
|
|
}
|
|
|
|
for (const node of workflow.nodes) {
|
|
if (!node.typeVersion || !node.type) continue;
|
|
|
|
const currentVersion = node.typeVersion.toString();
|
|
const latestVersion = this.versionService.getLatestVersion(node.type);
|
|
|
|
if (!latestVersion || currentVersion === latestVersion) continue;
|
|
|
|
// Check if this has breaking changes
|
|
const hasBreaking = this.breakingChangeDetector.hasBreakingChanges(
|
|
node.type,
|
|
currentVersion,
|
|
latestVersion
|
|
);
|
|
|
|
if (!hasBreaking) continue; // Already handled by typeversion-upgrade
|
|
|
|
// Analyze the migration
|
|
const analysis = await this.breakingChangeDetector.analyzeVersionUpgrade(
|
|
node.type,
|
|
currentVersion,
|
|
latestVersion
|
|
);
|
|
|
|
// Only proceed if there are non-auto-migratable changes
|
|
if (analysis.autoMigratableCount === analysis.changes.length) continue;
|
|
|
|
// Generate guidance for manual migration
|
|
const guidance = await this.postUpdateValidator.generateGuidance(
|
|
node.id,
|
|
node.name,
|
|
node.type,
|
|
currentVersion,
|
|
latestVersion,
|
|
{
|
|
success: false,
|
|
nodeId: node.id,
|
|
nodeName: node.name,
|
|
fromVersion: currentVersion,
|
|
toVersion: latestVersion,
|
|
appliedMigrations: [],
|
|
remainingIssues: analysis.recommendations,
|
|
confidence: analysis.overallSeverity === 'HIGH' ? 'LOW' : 'MEDIUM',
|
|
updatedNode: node
|
|
}
|
|
);
|
|
|
|
// Create a fix entry (won't be auto-applied, just documented)
|
|
fixes.push({
|
|
node: node.name,
|
|
field: 'typeVersion',
|
|
type: 'version-migration',
|
|
before: currentVersion,
|
|
after: latestVersion,
|
|
confidence: guidance.confidence === 'HIGH' ? 'medium' : 'low',
|
|
description: `Version migration required: ${node.name} v${currentVersion} → v${latestVersion}. ${analysis.manualRequiredCount} manual action(s) required.`
|
|
});
|
|
|
|
postUpdateGuidance.push(guidance);
|
|
|
|
logger.info(`Documented version migration for ${node.name}`, {
|
|
breakingChanges: analysis.changes.filter(c => c.isBreaking).length,
|
|
manualRequired: analysis.manualRequiredCount
|
|
});
|
|
}
|
|
}
|
|
} |