fix: field normalization, AI connection validation, autofix filter (#581) (#638)

- 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>
This commit is contained in:
Romuald Członkowski
2026-03-15 14:32:14 +01:00
committed by GitHub
parent 65ab94deb2
commit f7a1cfe8bf
41 changed files with 657 additions and 154 deletions

View File

@@ -1001,5 +1001,122 @@ describe('handlers-workflow-diff', () => {
expect(mockApiClient.updateWorkflowTags).not.toHaveBeenCalled();
});
});
describe('field name normalization', () => {
it('should normalize "name" to "nodeName" for updateNode operations', async () => {
const testWorkflow = createTestWorkflow();
const updatedWorkflow = { ...testWorkflow };
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
mockDiffEngine.applyDiff.mockResolvedValue({
success: true,
workflow: updatedWorkflow,
operationsApplied: 1,
message: 'Success',
errors: [],
});
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
await handleUpdatePartialWorkflow({
id: 'test-workflow-id',
operations: [{
type: 'updateNode',
name: 'HTTP Request', // LLMs often use "name" instead of "nodeName"
updates: { 'parameters.url': 'https://new-url.com' },
}],
}, mockRepository);
// Verify the diff engine received nodeName (normalized from name)
expect(mockDiffEngine.applyDiff).toHaveBeenCalled();
const diffArgs = mockDiffEngine.applyDiff.mock.calls[0][1];
expect(diffArgs.operations[0].nodeName).toBe('HTTP Request');
});
it('should normalize "id" to "nodeId" for removeNode operations', async () => {
const testWorkflow = createTestWorkflow();
const updatedWorkflow = { ...testWorkflow };
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
mockDiffEngine.applyDiff.mockResolvedValue({
success: true,
workflow: updatedWorkflow,
operationsApplied: 1,
message: 'Success',
errors: [],
});
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
await handleUpdatePartialWorkflow({
id: 'test-workflow-id',
operations: [{
type: 'removeNode',
id: 'node2', // LLMs may use "id" instead of "nodeId"
}],
}, mockRepository);
// Verify the diff engine received nodeId (normalized from id)
expect(mockDiffEngine.applyDiff).toHaveBeenCalled();
const diffArgs = mockDiffEngine.applyDiff.mock.calls[0][1];
expect(diffArgs.operations[0].nodeId).toBe('node2');
});
it('should NOT normalize "name" for updateName operations', async () => {
const testWorkflow = createTestWorkflow();
const updatedWorkflow = { ...testWorkflow };
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
mockDiffEngine.applyDiff.mockResolvedValue({
success: true,
workflow: updatedWorkflow,
operationsApplied: 1,
message: 'Success',
errors: [],
});
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
await handleUpdatePartialWorkflow({
id: 'test-workflow-id',
operations: [{
type: 'updateName',
name: 'New Workflow Name', // This is the correct field for updateName
}],
}, mockRepository);
// Verify "name" stays as "name" (not moved to nodeName) for updateName
expect(mockDiffEngine.applyDiff).toHaveBeenCalled();
const diffArgs = mockDiffEngine.applyDiff.mock.calls[0][1];
expect(diffArgs.operations[0].name).toBe('New Workflow Name');
expect(diffArgs.operations[0].nodeName).toBeUndefined();
});
it('should prefer explicit "nodeName" over "name" alias', async () => {
const testWorkflow = createTestWorkflow();
const updatedWorkflow = { ...testWorkflow };
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
mockDiffEngine.applyDiff.mockResolvedValue({
success: true,
workflow: updatedWorkflow,
operationsApplied: 1,
message: 'Success',
errors: [],
});
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
await handleUpdatePartialWorkflow({
id: 'test-workflow-id',
operations: [{
type: 'updateNode',
nodeName: 'HTTP Request', // Explicit nodeName provided
name: 'Should Be Ignored', // Should NOT override nodeName
updates: { 'parameters.url': 'https://new-url.com' },
}],
}, mockRepository);
expect(mockDiffEngine.applyDiff).toHaveBeenCalled();
const diffArgs = mockDiffEngine.applyDiff.mock.calls[0][1];
expect(diffArgs.operations[0].nodeName).toBe('HTTP Request');
});
});
});
});