mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 14:32:04 +00:00
Compare commits
117 Commits
v2.18.6
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4df9558b3e | ||
|
|
5d2c5df53e | ||
|
|
f5cf1e2934 | ||
|
|
9050967cd6 | ||
|
|
717d6f927f | ||
|
|
fc37907348 | ||
|
|
47d9f55dc5 | ||
|
|
5575630711 | ||
|
|
1bbfaabbc2 | ||
|
|
597bd290b6 | ||
|
|
99c5907b71 | ||
|
|
77151e013e | ||
|
|
14f3b9c12a | ||
|
|
eb362febd6 | ||
|
|
821ace310e | ||
|
|
53252adc68 | ||
|
|
2010d77ed8 | ||
|
|
caf9383ba1 | ||
|
|
8728a808ac | ||
|
|
60ab66d64d | ||
|
|
eee52a7f53 | ||
|
|
a66cb18cce | ||
|
|
0e0f0998af | ||
|
|
08a4be8370 | ||
|
|
3578f2cc31 | ||
|
|
4d3b8fbc91 | ||
|
|
5688384113 | ||
|
|
346fa3c8d2 | ||
|
|
3d5ceae43f | ||
|
|
1834d474a5 | ||
|
|
a4ef1efaf8 | ||
|
|
65f51ad8b5 | ||
|
|
af6efe9e88 | ||
|
|
3f427f9528 | ||
|
|
18b8747005 | ||
|
|
749f1c53eb | ||
|
|
892c4ed70a | ||
|
|
590dc087ac | ||
|
|
ee7229b4db | ||
|
|
b6683b8381 | ||
|
|
b2300429fd | ||
|
|
b87f638e52 | ||
|
|
1f94427d54 | ||
|
|
2eb459c80c | ||
|
|
79ef853e8c | ||
|
|
2682be33b8 | ||
|
|
9f291154f2 | ||
|
|
bfff497020 | ||
|
|
e522aec08c | ||
|
|
817bf7d211 | ||
|
|
9a3520adb7 | ||
|
|
ced7fafcbf | ||
|
|
ad4b521402 | ||
|
|
b18f6ec7a4 | ||
|
|
95ea6ca0bb | ||
|
|
a4c7e097e8 | ||
|
|
0778c55d85 | ||
|
|
913ff31164 | ||
|
|
952a97ef73 | ||
|
|
56114f041b | ||
|
|
c52a3dd253 | ||
|
|
bc156fce2a | ||
|
|
aaa6be6d74 | ||
|
|
3806efdbd8 | ||
|
|
0e26ea6a68 | ||
|
|
1bfbf05561 | ||
|
|
f23e09934d | ||
|
|
5ea00e12a2 | ||
|
|
04e7c53b59 | ||
|
|
c7f8614de1 | ||
|
|
5702a64a01 | ||
|
|
551fea841b | ||
|
|
eac4e67101 | ||
|
|
c76ffd9fb1 | ||
|
|
7300957d13 | ||
|
|
32a25e2706 | ||
|
|
ab6b554692 | ||
|
|
32264da107 | ||
|
|
ef1cf747a3 | ||
|
|
dbdc88d629 | ||
|
|
538618b1bc | ||
|
|
41830c88fe | ||
|
|
0d2d9bdd52 | ||
|
|
05f68b8ea1 | ||
|
|
5881304ed8 | ||
|
|
0f5b0d9463 | ||
|
|
4399899255 | ||
|
|
8d20c64f5c | ||
|
|
fe1309151a | ||
|
|
dd62040155 | ||
|
|
112b40119c | ||
|
|
318986f546 | ||
|
|
aa8a6a7069 | ||
|
|
e11a885b0d | ||
|
|
ee99cb7ba1 | ||
|
|
66cb66b31b | ||
|
|
b67d6ba353 | ||
|
|
3ba5584df9 | ||
|
|
be0211d826 | ||
|
|
0d71a16f83 | ||
|
|
085f6db7a2 | ||
|
|
b6bc3b732e | ||
|
|
c16c9a2398 | ||
|
|
1d34ad81d5 | ||
|
|
4566253bdc | ||
|
|
54c598717c | ||
|
|
8b5b01de98 | ||
|
|
275e573d8d | ||
|
|
6256105053 | ||
|
|
1f43784315 | ||
|
|
80e3391773 | ||
|
|
c580a3dde4 | ||
|
|
fc8fb66900 | ||
|
|
4625ebf64d | ||
|
|
43dea68f0b | ||
|
|
dc62fd66cb | ||
|
|
a94ff0586c |
@@ -26,4 +26,8 @@ USE_NGINX=false
|
||||
# N8N_API_URL=https://your-n8n-instance.com
|
||||
# N8N_API_KEY=your-api-key-here
|
||||
# N8N_API_TIMEOUT=30000
|
||||
# N8N_API_MAX_RETRIES=3
|
||||
# N8N_API_MAX_RETRIES=3
|
||||
|
||||
# Optional: Disable specific tools (comma-separated list)
|
||||
# Example: DISABLED_TOOLS=n8n_diagnostic,n8n_health_check
|
||||
# DISABLED_TOOLS=
|
||||
17
.env.example
17
.env.example
@@ -103,6 +103,23 @@ AUTH_TOKEN=your-secure-token-here
|
||||
# For local development with local n8n:
|
||||
# WEBHOOK_SECURITY_MODE=moderate
|
||||
|
||||
# Disabled Tools Configuration
|
||||
# Filter specific tools from registration at startup
|
||||
# Useful for multi-tenant deployments, security hardening, or feature flags
|
||||
#
|
||||
# Format: Comma-separated list of tool names
|
||||
# Example: DISABLED_TOOLS=n8n_diagnostic,n8n_health_check,custom_tool
|
||||
#
|
||||
# Common use cases:
|
||||
# - Multi-tenant: Hide tools that check env vars instead of instance context
|
||||
# Example: DISABLED_TOOLS=n8n_diagnostic,n8n_health_check
|
||||
# - Security: Disable management tools in production for certain users
|
||||
# - Feature flags: Gradually roll out new tools
|
||||
# - Deployment-specific: Different tool sets for cloud vs self-hosted
|
||||
#
|
||||
# Default: (empty - all tools enabled)
|
||||
# DISABLED_TOOLS=
|
||||
|
||||
# =========================
|
||||
# MULTI-TENANT CONFIGURATION
|
||||
# =========================
|
||||
|
||||
52
.github/workflows/docker-build.yml
vendored
52
.github/workflows/docker-build.yml
vendored
@@ -5,8 +5,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '**.txt'
|
||||
@@ -38,6 +36,12 @@ on:
|
||||
- 'CODE_OF_CONDUCT.md'
|
||||
workflow_dispatch:
|
||||
|
||||
# Prevent concurrent Docker pushes across all workflows (shared with release.yml)
|
||||
# This ensures docker-build.yml and release.yml never push to 'latest' simultaneously
|
||||
concurrency:
|
||||
group: docker-push-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
@@ -89,16 +93,54 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
no-cache: true
|
||||
no-cache: false
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
- name: Verify multi-arch manifest for latest tag
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "Verifying multi-arch manifest for latest tag..."
|
||||
|
||||
# Retry with exponential backoff (registry propagation can take time)
|
||||
MAX_ATTEMPTS=5
|
||||
ATTEMPT=1
|
||||
WAIT_TIME=2
|
||||
|
||||
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
|
||||
echo "Attempt $ATTEMPT of $MAX_ATTEMPTS..."
|
||||
|
||||
MANIFEST=$(docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 2>&1 || true)
|
||||
|
||||
# Check for both platforms
|
||||
if echo "$MANIFEST" | grep -q "linux/amd64" && echo "$MANIFEST" | grep -q "linux/arm64"; then
|
||||
echo "✅ Multi-arch manifest verified: both amd64 and arm64 present"
|
||||
echo "$MANIFEST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
|
||||
echo "⏳ Registry still propagating, waiting ${WAIT_TIME}s before retry..."
|
||||
sleep $WAIT_TIME
|
||||
WAIT_TIME=$((WAIT_TIME * 2)) # Exponential backoff: 2s, 4s, 8s, 16s
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "❌ ERROR: Multi-arch manifest incomplete after $MAX_ATTEMPTS attempts!"
|
||||
echo "$MANIFEST"
|
||||
exit 1
|
||||
|
||||
build-railway:
|
||||
name: Build Railway Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -143,11 +185,13 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.railway
|
||||
no-cache: true
|
||||
no-cache: false
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-railway.outputs.tags }}
|
||||
labels: ${{ steps.meta-railway.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
# Nginx build commented out until Phase 2
|
||||
|
||||
165
.github/workflows/release.yml
vendored
165
.github/workflows/release.yml
vendored
@@ -13,9 +13,10 @@ permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
# Prevent concurrent releases
|
||||
# Prevent concurrent Docker pushes across all workflows (shared with docker-build.yml)
|
||||
# This ensures release.yml and docker-build.yml never push to 'latest' simultaneously
|
||||
concurrency:
|
||||
group: release
|
||||
group: docker-push-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
@@ -111,53 +112,79 @@ jobs:
|
||||
|
||||
echo "✅ Version $CURRENT_VERSION is valid (higher than npm version $NPM_VERSION)"
|
||||
|
||||
extract-changelog:
|
||||
name: Extract Changelog
|
||||
generate-release-notes:
|
||||
name: Generate Release Notes
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect-version-change
|
||||
if: needs.detect-version-change.outputs.version-changed == 'true'
|
||||
outputs:
|
||||
release-notes: ${{ steps.extract.outputs.notes }}
|
||||
has-notes: ${{ steps.extract.outputs.has-notes }}
|
||||
release-notes: ${{ steps.generate.outputs.notes }}
|
||||
has-notes: ${{ steps.generate.outputs.has-notes }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract changelog for version
|
||||
id: extract
|
||||
with:
|
||||
fetch-depth: 0 # Need full history for git log
|
||||
|
||||
- name: Generate release notes from commits
|
||||
id: generate
|
||||
run: |
|
||||
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
|
||||
CHANGELOG_FILE="docs/CHANGELOG.md"
|
||||
|
||||
if [ ! -f "$CHANGELOG_FILE" ]; then
|
||||
echo "Changelog file not found at $CHANGELOG_FILE"
|
||||
echo "has-notes=false" >> $GITHUB_OUTPUT
|
||||
echo "notes=No changelog entries found for version $VERSION" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use the extracted changelog script
|
||||
if NOTES=$(node scripts/extract-changelog.js "$VERSION" "$CHANGELOG_FILE" 2>/dev/null); then
|
||||
CURRENT_VERSION="${{ needs.detect-version-change.outputs.new-version }}"
|
||||
CURRENT_TAG="v$CURRENT_VERSION"
|
||||
|
||||
# Get the previous tag (excluding the current tag which doesn't exist yet)
|
||||
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^$CURRENT_TAG$" | head -1)
|
||||
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
echo "Current tag: $CURRENT_TAG"
|
||||
echo "Previous tag: $PREVIOUS_TAG"
|
||||
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "ℹ️ No previous tag found, this might be the first release"
|
||||
|
||||
# Generate initial release notes using script
|
||||
if NOTES=$(node scripts/generate-initial-release-notes.js "$CURRENT_VERSION" 2>/dev/null); then
|
||||
echo "✅ Successfully generated initial release notes for version $CURRENT_VERSION"
|
||||
else
|
||||
echo "⚠️ Could not generate initial release notes for version $CURRENT_VERSION"
|
||||
NOTES="Initial release v$CURRENT_VERSION"
|
||||
fi
|
||||
|
||||
echo "has-notes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Use heredoc to properly handle multiline content
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
echo "$NOTES"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
echo "✅ Successfully extracted changelog for version $VERSION"
|
||||
|
||||
else
|
||||
echo "has-notes=false" >> $GITHUB_OUTPUT
|
||||
echo "notes=No changelog entries found for version $VERSION" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Could not extract changelog for version $VERSION"
|
||||
echo "✅ Previous tag found: $PREVIOUS_TAG"
|
||||
|
||||
# Generate release notes between tags
|
||||
if NOTES=$(node scripts/generate-release-notes.js "$PREVIOUS_TAG" "HEAD" 2>/dev/null); then
|
||||
echo "has-notes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
# Use heredoc to properly handle multiline content
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
echo "$NOTES"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
echo "✅ Successfully generated release notes from $PREVIOUS_TAG to $CURRENT_TAG"
|
||||
else
|
||||
echo "has-notes=false" >> $GITHUB_OUTPUT
|
||||
echo "notes=Failed to generate release notes for version $CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Could not generate release notes for version $CURRENT_VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-version-change, extract-changelog]
|
||||
needs: [detect-version-change, generate-release-notes]
|
||||
if: needs.detect-version-change.outputs.version-changed == 'true'
|
||||
outputs:
|
||||
release-id: ${{ steps.create.outputs.id }}
|
||||
@@ -188,7 +215,7 @@ jobs:
|
||||
cat > release_body.md << 'EOF'
|
||||
# Release v${{ needs.detect-version-change.outputs.new-version }}
|
||||
|
||||
${{ needs.extract-changelog.outputs.release-notes }}
|
||||
${{ needs.generate-release-notes.outputs.release-notes }}
|
||||
|
||||
---
|
||||
|
||||
@@ -334,6 +361,15 @@ jobs:
|
||||
const pkg = require('./package.json');
|
||||
pkg.name = 'n8n-mcp';
|
||||
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (MCP)';
|
||||
pkg.main = 'dist/index.js';
|
||||
pkg.types = 'dist/index.d.ts';
|
||||
pkg.exports = {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
require: './dist/index.js',
|
||||
import: './dist/index.js'
|
||||
}
|
||||
};
|
||||
pkg.bin = { 'n8n-mcp': './dist/mcp/index.js' };
|
||||
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
|
||||
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
|
||||
@@ -426,7 +462,76 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
- name: Verify multi-arch manifest for latest tag
|
||||
run: |
|
||||
echo "Verifying multi-arch manifest for latest tag..."
|
||||
|
||||
# Retry with exponential backoff (registry propagation can take time)
|
||||
MAX_ATTEMPTS=5
|
||||
ATTEMPT=1
|
||||
WAIT_TIME=2
|
||||
|
||||
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
|
||||
echo "Attempt $ATTEMPT of $MAX_ATTEMPTS..."
|
||||
|
||||
MANIFEST=$(docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 2>&1 || true)
|
||||
|
||||
# Check for both platforms
|
||||
if echo "$MANIFEST" | grep -q "linux/amd64" && echo "$MANIFEST" | grep -q "linux/arm64"; then
|
||||
echo "✅ Multi-arch manifest verified: both amd64 and arm64 present"
|
||||
echo "$MANIFEST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
|
||||
echo "⏳ Registry still propagating, waiting ${WAIT_TIME}s before retry..."
|
||||
sleep $WAIT_TIME
|
||||
WAIT_TIME=$((WAIT_TIME * 2)) # Exponential backoff: 2s, 4s, 8s, 16s
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "❌ ERROR: Multi-arch manifest incomplete after $MAX_ATTEMPTS attempts!"
|
||||
echo "$MANIFEST"
|
||||
exit 1
|
||||
|
||||
- name: Verify multi-arch manifest for version tag
|
||||
run: |
|
||||
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
|
||||
echo "Verifying multi-arch manifest for version tag :$VERSION (without 'v' prefix)..."
|
||||
|
||||
# Retry with exponential backoff (registry propagation can take time)
|
||||
MAX_ATTEMPTS=5
|
||||
ATTEMPT=1
|
||||
WAIT_TIME=2
|
||||
|
||||
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
|
||||
echo "Attempt $ATTEMPT of $MAX_ATTEMPTS..."
|
||||
|
||||
MANIFEST=$(docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$VERSION 2>&1 || true)
|
||||
|
||||
# Check for both platforms
|
||||
if echo "$MANIFEST" | grep -q "linux/amd64" && echo "$MANIFEST" | grep -q "linux/arm64"; then
|
||||
echo "✅ Multi-arch manifest verified for $VERSION: both amd64 and arm64 present"
|
||||
echo "$MANIFEST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
|
||||
echo "⏳ Registry still propagating, waiting ${WAIT_TIME}s before retry..."
|
||||
sleep $WAIT_TIME
|
||||
WAIT_TIME=$((WAIT_TIME * 2)) # Exponential backoff: 2s, 4s, 8s, 16s
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "❌ ERROR: Multi-arch manifest incomplete for version $VERSION after $MAX_ATTEMPTS attempts!"
|
||||
echo "$MANIFEST"
|
||||
exit 1
|
||||
|
||||
- name: Extract metadata for Railway image
|
||||
id: meta-railway
|
||||
uses: docker/metadata-action@v5
|
||||
|
||||
209
ANALYSIS_QUICK_REFERENCE.md
Normal file
209
ANALYSIS_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# N8N-MCP Validation Analysis: Quick Reference
|
||||
|
||||
**Analysis Date**: November 8, 2025 | **Data Period**: 90 days | **Sample Size**: 29,218 events
|
||||
|
||||
---
|
||||
|
||||
## The Core Finding
|
||||
|
||||
**Validation is working perfectly. Guidance is the problem.**
|
||||
|
||||
- 29,218 validation events successfully prevented bad deployments
|
||||
- 100% of agents fix errors same-day (proving feedback works)
|
||||
- 12.6% error rate for advanced users (who attempt complex workflows)
|
||||
- High error volume = high usage, not broken system
|
||||
|
||||
---
|
||||
|
||||
## Top 3 Problem Areas (75% of errors)
|
||||
|
||||
| Area | Errors | Root Cause | Quick Fix |
|
||||
|------|--------|-----------|-----------|
|
||||
| **Workflow Structure** | 1,268 (26%) | JSON malformation | Better error messages with examples |
|
||||
| **Connections** | 676 (14%) | Syntax unintuitive | Create connections guide with diagrams |
|
||||
| **Required Fields** | 378 (8%) | Not marked upfront | Add "⚠️ REQUIRED" to tool responses |
|
||||
|
||||
---
|
||||
|
||||
## Problem Nodes (By Frequency)
|
||||
|
||||
```
|
||||
Webhook/Trigger ......... 127 failures (40 users)
|
||||
Slack .................. 73 failures (2 users)
|
||||
AI Agent ............... 36 failures (20 users)
|
||||
HTTP Request ........... 31 failures (13 users)
|
||||
OpenAI ................. 35 failures (8 users)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Top 5 Validation Errors
|
||||
|
||||
1. **"Duplicate node ID: undefined"** (179)
|
||||
- Fix: Point to exact location + show example format
|
||||
|
||||
2. **"Single-node workflows only valid for webhooks"** (58)
|
||||
- Fix: Create webhook guide explaining rule
|
||||
|
||||
3. **"responseNode requires onError: continueRegularOutput"** (57)
|
||||
- Fix: Same guide + inline error context
|
||||
|
||||
4. **"Required property X cannot be empty"** (25)
|
||||
- Fix: Mark required fields before validation
|
||||
|
||||
5. **"Duplicate node name: undefined"** (61)
|
||||
- Fix: Related to structural issues, same solution as #1
|
||||
|
||||
---
|
||||
|
||||
## Success Indicators
|
||||
|
||||
✓ **Agents learn from errors**: 100% same-day correction rate
|
||||
✓ **Validation catches issues**: Prevents bad deployments
|
||||
✓ **Feedback is clear**: Quick fixes show error messages work
|
||||
✓ **No systemic failures**: No "unfixable" errors
|
||||
|
||||
---
|
||||
|
||||
## What Works Well
|
||||
|
||||
- Error messages lead to immediate corrections
|
||||
- Agents retry and succeed same-day
|
||||
- Validation prevents broken workflows
|
||||
- 9,021 users actively using system
|
||||
|
||||
---
|
||||
|
||||
## What Needs Improvement
|
||||
|
||||
1. Required fields not marked in tool responses
|
||||
2. Error messages don't show valid options for enums
|
||||
3. Workflow structure documentation lacks examples
|
||||
4. Connection syntax unintuitive/undocumented
|
||||
5. Some error messages too generic
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1 (2 weeks): Quick Wins
|
||||
- Enhanced error messages (location + example)
|
||||
- Required field markers in tools
|
||||
- Webhook configuration guide
|
||||
- **Expected Impact**: 25-30% failure reduction
|
||||
|
||||
### Phase 2 (2 weeks): Documentation
|
||||
- Enum value suggestions in validation
|
||||
- Workflow connections guide
|
||||
- Error handler configuration guide
|
||||
- AI Agent validation improvements
|
||||
- **Expected Impact**: Additional 15-20% reduction
|
||||
|
||||
### Phase 3 (2 weeks): Advanced Features
|
||||
- Improved search with config hints
|
||||
- Node type fuzzy matching
|
||||
- KPI tracking setup
|
||||
- Test coverage
|
||||
- **Expected Impact**: Additional 10-15% reduction
|
||||
|
||||
**Total Impact**: 50-65% failure reduction (target: 6-7% error rate)
|
||||
|
||||
---
|
||||
|
||||
## Key Metrics
|
||||
|
||||
| Metric | Current | Target | Timeline |
|
||||
|--------|---------|--------|----------|
|
||||
| Validation failure rate | 12.6% | 6-7% | 6 weeks |
|
||||
| First-attempt success | ~77% | 85%+ | 6 weeks |
|
||||
| Retry success | 100% | 100% | N/A |
|
||||
| Webhook failures | 127 | <30 | Week 2 |
|
||||
| Connection errors | 676 | <270 | Week 4 |
|
||||
|
||||
---
|
||||
|
||||
## Files Delivered
|
||||
|
||||
1. **VALIDATION_ANALYSIS_REPORT.md** (27KB)
|
||||
- Complete analysis with 16 SQL queries
|
||||
- Detailed findings by category
|
||||
- 8 actionable recommendations
|
||||
|
||||
2. **VALIDATION_ANALYSIS_SUMMARY.md** (13KB)
|
||||
- Executive summary (one-page)
|
||||
- Key metrics scorecard
|
||||
- Top recommendations with ROI
|
||||
|
||||
3. **IMPLEMENTATION_ROADMAP.md** (4.3KB)
|
||||
- 6-week implementation plan
|
||||
- Phase-by-phase breakdown
|
||||
- Code locations and effort estimates
|
||||
|
||||
4. **ANALYSIS_QUICK_REFERENCE.md** (this file)
|
||||
- Quick lookup reference
|
||||
- Top problems at a glance
|
||||
- Decision-making summary
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Week 1**: Review analysis + get team approval
|
||||
2. **Week 2**: Start Phase 1 (error messages + markers)
|
||||
3. **Week 4**: Deploy Phase 1 + start Phase 2
|
||||
4. **Week 6**: Deploy Phase 2 + start Phase 3
|
||||
5. **Week 8**: Deploy Phase 3 + measure impact
|
||||
6. **Week 9+**: Monitor KPIs + iterate
|
||||
|
||||
---
|
||||
|
||||
## Key Recommendations Priority
|
||||
|
||||
### HIGH (Do First - Week 1-2)
|
||||
1. Enhance structure error messages
|
||||
2. Add required field markers to tools
|
||||
3. Create webhook configuration guide
|
||||
|
||||
### MEDIUM (Do Next - Week 3-4)
|
||||
4. Add enum suggestions to validation responses
|
||||
5. Create workflow connections guide
|
||||
6. Add AI Agent node validation
|
||||
|
||||
### LOW (Do Later - Week 5-6)
|
||||
7. Enhance search with config hints
|
||||
8. Build fuzzy node matcher
|
||||
9. Setup KPI tracking
|
||||
|
||||
---
|
||||
|
||||
## Discussion Points
|
||||
|
||||
**Q: Why don't we just weaken validation?**
|
||||
A: Validation prevents 29,218 bad deployments. That's its job. We improve guidance instead.
|
||||
|
||||
**Q: Are agents really learning from errors?**
|
||||
A: Yes, 100% same-day recovery across 661 user-date pairs with errors.
|
||||
|
||||
**Q: Why do documentation readers have higher error rates?**
|
||||
A: They attempt more complex workflows (6.8x more attempts). Success rate is still 87.4%.
|
||||
|
||||
**Q: Which node needs the most help?**
|
||||
A: Webhook/Trigger configuration (127 failures). Most urgent fix.
|
||||
|
||||
**Q: Can we hit 50% reduction in 6 weeks?**
|
||||
A: Yes, analysis shows 50-65% reduction is achievable with these changes.
|
||||
|
||||
---
|
||||
|
||||
## Contact & Questions
|
||||
|
||||
For detailed information:
|
||||
- Full analysis: `VALIDATION_ANALYSIS_REPORT.md`
|
||||
- Executive summary: `VALIDATION_ANALYSIS_SUMMARY.md`
|
||||
- Implementation plan: `IMPLEMENTATION_ROADMAP.md`
|
||||
|
||||
---
|
||||
|
||||
**Report Status**: Complete and Ready for Action
|
||||
**Confidence Level**: High (9,021 users, 29,218 events, comprehensive analysis)
|
||||
**Generated**: November 8, 2025
|
||||
3340
CHANGELOG.md
3340
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
44
CLAUDE.md
44
CLAUDE.md
@@ -28,8 +28,15 @@ src/
|
||||
│ ├── enhanced-config-validator.ts # Operation-aware validation (NEW in v2.4.2)
|
||||
│ ├── node-specific-validators.ts # Node-specific validation logic (NEW in v2.4.2)
|
||||
│ ├── property-dependencies.ts # Dependency analysis (NEW in v2.4)
|
||||
│ ├── type-structure-service.ts # Type structure validation (NEW in v2.22.21)
|
||||
│ ├── expression-validator.ts # n8n expression syntax validation (NEW in v2.5.0)
|
||||
│ └── workflow-validator.ts # Complete workflow validation (NEW in v2.5.0)
|
||||
├── types/
|
||||
│ ├── type-structures.ts # Type structure definitions (NEW in v2.22.21)
|
||||
│ ├── instance-context.ts # Multi-tenant instance configuration
|
||||
│ └── session-state.ts # Session persistence types (NEW in v2.24.1)
|
||||
├── constants/
|
||||
│ └── type-structures.ts # 22 complete type structures (NEW in v2.22.21)
|
||||
├── templates/
|
||||
│ ├── template-fetcher.ts # Fetches templates from n8n.io API (NEW in v2.4.1)
|
||||
│ ├── template-repository.ts # Template database operations (NEW in v2.4.1)
|
||||
@@ -40,6 +47,7 @@ src/
|
||||
│ ├── test-nodes.ts # Critical node tests
|
||||
│ ├── test-essentials.ts # Test new essentials tools (NEW in v2.4)
|
||||
│ ├── test-enhanced-validation.ts # Test enhanced validation (NEW in v2.4.2)
|
||||
│ ├── test-structure-validation.ts # Test type structure validation (NEW in v2.22.21)
|
||||
│ ├── test-workflow-validation.ts # Test workflow validation (NEW in v2.5.0)
|
||||
│ ├── test-ai-workflow-validation.ts # Test AI workflow validation (NEW in v2.5.1)
|
||||
│ ├── test-mcp-tools.ts # Test MCP tool enhancements (NEW in v2.5.1)
|
||||
@@ -58,7 +66,9 @@ src/
|
||||
│ ├── console-manager.ts # Console output isolation (NEW in v2.3.1)
|
||||
│ └── logger.ts # Logging utility with HTTP awareness
|
||||
├── http-server-single-session.ts # Single-session HTTP server (NEW in v2.3.1)
|
||||
│ # Session persistence API (NEW in v2.24.1)
|
||||
├── mcp-engine.ts # Clean API for service integration (NEW in v2.3.1)
|
||||
│ # Session persistence wrappers (NEW in v2.24.1)
|
||||
└── index.ts # Library exports
|
||||
```
|
||||
|
||||
@@ -76,6 +86,7 @@ npm run test:unit # Run unit tests only
|
||||
npm run test:integration # Run integration tests
|
||||
npm run test:coverage # Run tests with coverage report
|
||||
npm run test:watch # Run tests in watch mode
|
||||
npm run test:structure-validation # Test type structure validation (Phase 3)
|
||||
|
||||
# Run a single test file
|
||||
npm test -- tests/unit/services/property-filter.test.ts
|
||||
@@ -126,6 +137,7 @@ npm run test:templates # Test template functionality
|
||||
4. **Service Layer** (`services/`)
|
||||
- **Property Filter**: Reduces node properties to AI-friendly essentials
|
||||
- **Config Validator**: Multi-profile validation system
|
||||
- **Type Structure Service**: Validates complex type structures (filter, resourceMapper, etc.)
|
||||
- **Expression Validator**: Validates n8n expression syntax
|
||||
- **Workflow Validator**: Complete workflow structure validation
|
||||
|
||||
@@ -183,6 +195,35 @@ The MCP server exposes tools in several categories:
|
||||
### Development Best Practices
|
||||
- Run typecheck and lint after every code change
|
||||
|
||||
### Session Persistence Feature (v2.24.1)
|
||||
|
||||
**Location:**
|
||||
- Types: `src/types/session-state.ts`
|
||||
- Implementation: `src/http-server-single-session.ts` (lines 698-702, 1444-1584)
|
||||
- Wrapper: `src/mcp-engine.ts` (lines 123-169)
|
||||
- Tests: `tests/unit/http-server/session-persistence.test.ts`, `tests/unit/mcp-engine/session-persistence.test.ts`
|
||||
|
||||
**Key Features:**
|
||||
- **Export/Restore API**: `exportSessionState()` and `restoreSessionState()` methods
|
||||
- **Multi-tenant support**: Enables zero-downtime deployments for SaaS platforms
|
||||
- **Security-first**: API keys exported as plaintext - downstream MUST encrypt
|
||||
- **Dormant sessions**: Restored sessions recreate transports on first request
|
||||
- **Automatic expiration**: Respects `sessionTimeout` setting (default 30 min)
|
||||
- **MAX_SESSIONS limit**: Caps at 100 concurrent sessions
|
||||
|
||||
**Important Implementation Notes:**
|
||||
- Only exports sessions with valid n8nApiUrl and n8nApiKey in context
|
||||
- Skips expired sessions during both export and restore
|
||||
- Uses `validateInstanceContext()` for data integrity checks
|
||||
- Handles null/invalid session gracefully with warnings
|
||||
- Session metadata (timestamps) and context (credentials) are persisted
|
||||
- Transport and server objects are NOT persisted (recreated on-demand)
|
||||
|
||||
**Testing:**
|
||||
- 22 unit tests covering export, restore, edge cases, and round-trip cycles
|
||||
- Tests use current timestamps to avoid expiration issues
|
||||
- Integration with multi-tenant backends documented in README.md
|
||||
|
||||
# important-instruction-reminders
|
||||
Do what has been asked; nothing more, nothing less.
|
||||
NEVER create files unless they're absolutely necessary for achieving your goal.
|
||||
@@ -192,4 +233,5 @@ NEVER proactively create documentation files (*.md) or README files. Only create
|
||||
- When the user asks to review issues, you should use GH CLI to get the issue and all the comments
|
||||
- When the task can be divided into separated subtasks, you should spawn separate sub-agents to handle them in paralel
|
||||
- Use the best sub-agent for the task as per their descriptions
|
||||
- Do not use hyperbolic or dramatic language in comments and documentation
|
||||
- Do not use hyperbolic or dramatic language in comments and documentation
|
||||
- Add to every commit and PR: Concieved by Romuald Członkowski - and then link to www.aiadvisors.pl/en. Don't add it in conversations
|
||||
12
Dockerfile
12
Dockerfile
@@ -34,9 +34,13 @@ RUN apk add --no-cache curl su-exec && \
|
||||
# Copy runtime-only package.json
|
||||
COPY package.runtime.json package.json
|
||||
|
||||
# Install runtime dependencies with cache mount
|
||||
# Install runtime dependencies with better-sqlite3 compilation
|
||||
# Build tools (python3, make, g++) are installed, used for compilation, then removed
|
||||
# This enables native SQLite (better-sqlite3) instead of sql.js, preventing memory leaks
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --production --no-audit --no-fund
|
||||
apk add --no-cache python3 make g++ && \
|
||||
npm install --production --no-audit --no-fund && \
|
||||
apk del python3 make g++
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
@@ -78,7 +82,7 @@ ENV IS_DOCKER=true
|
||||
# To opt-out, uncomment the following line:
|
||||
# ENV N8N_MCP_TELEMETRY_DISABLED=true
|
||||
|
||||
# Expose HTTP port
|
||||
# Expose HTTP port (default 3000, configurable via PORT environment variable at runtime)
|
||||
EXPOSE 3000
|
||||
|
||||
# Set stop signal to SIGTERM (default, but explicit is better)
|
||||
@@ -86,7 +90,7 @@ STOPSIGNAL SIGTERM
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://127.0.0.1:3000/health || exit 1
|
||||
CMD sh -c 'curl -f http://127.0.0.1:${PORT:-3000}/health || exit 1'
|
||||
|
||||
# Optimized entrypoint
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
@@ -25,16 +25,20 @@ RUN npm run build
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache curl python3 make g++ && \
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache curl && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
# Copy runtime-only package.json
|
||||
COPY package.runtime.json package.json
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm install --production --no-audit --no-fund && \
|
||||
npm cache clean --force
|
||||
# Install production dependencies with temporary build tools
|
||||
# Build tools (python3, make, g++) enable better-sqlite3 compilation (native SQLite)
|
||||
# They are removed after installation to reduce image size and attack surface
|
||||
RUN apk add --no-cache python3 make g++ && \
|
||||
npm install --production --no-audit --no-fund && \
|
||||
npm cache clean --force && \
|
||||
apk del python3 make g++
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# n8n Update Process - Quick Reference
|
||||
|
||||
## ⚡ Recommended Fast Workflow (2025-11-04)
|
||||
|
||||
**CRITICAL FIRST STEP**: Check existing releases to avoid version conflicts!
|
||||
|
||||
```bash
|
||||
# 1. CHECK EXISTING RELEASES FIRST (prevents version conflicts!)
|
||||
gh release list | head -5
|
||||
# Look at the latest version - your new version must be higher!
|
||||
|
||||
# 2. Switch to main and pull
|
||||
git checkout main && git pull
|
||||
|
||||
# 3. Check for updates (dry run)
|
||||
npm run update:n8n:check
|
||||
|
||||
# 4. Run update and skip tests (we'll test in CI)
|
||||
yes y | npm run update:n8n
|
||||
|
||||
# 5. Create feature branch
|
||||
git checkout -b update/n8n-X.X.X
|
||||
|
||||
# 6. Update version in package.json (must be HIGHER than latest release!)
|
||||
# Edit: "version": "2.XX.X" (not the version from the release list!)
|
||||
|
||||
# 7. Update CHANGELOG.md
|
||||
# - Change version number to match package.json
|
||||
# - Update date to today
|
||||
# - Update dependency versions
|
||||
|
||||
# 8. Update README badge
|
||||
# Edit line 8: Change n8n version badge to new n8n version
|
||||
|
||||
# 9. Commit and push
|
||||
git add -A
|
||||
git commit -m "chore: update n8n to X.X.X and bump version to 2.XX.X
|
||||
|
||||
- Updated n8n from X.X.X to X.X.X
|
||||
- Updated n8n-core from X.X.X to X.X.X
|
||||
- Updated n8n-workflow from X.X.X to X.X.X
|
||||
- Updated @n8n/n8n-nodes-langchain from X.X.X to X.X.X
|
||||
- Rebuilt node database with XXX nodes (XXX from n8n-nodes-base, XXX from @n8n/n8n-nodes-langchain)
|
||||
- Updated README badge with new n8n version
|
||||
- Updated CHANGELOG with dependency changes
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
|
||||
git push -u origin update/n8n-X.X.X
|
||||
|
||||
# 10. Create PR
|
||||
gh pr create --title "chore: update n8n to X.X.X" --body "Updates n8n and all related dependencies to the latest versions..."
|
||||
|
||||
# 11. After PR is merged, verify release triggered
|
||||
gh release list | head -1
|
||||
# If the new version appears, you're done!
|
||||
# If not, the version might have already been released - bump version again and create new PR
|
||||
```
|
||||
|
||||
### Why This Workflow?
|
||||
|
||||
✅ **Fast**: Skip local tests (2-3 min saved) - CI runs them anyway
|
||||
✅ **Safe**: Unit tests in CI verify compatibility
|
||||
✅ **Clean**: All changes in one PR with proper tracking
|
||||
✅ **Automatic**: Release workflow triggers on merge if version is new
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Problem**: Release workflow doesn't trigger after merge
|
||||
**Cause**: Version number was already released (check `gh release list`)
|
||||
**Solution**: Create new PR bumping version by one patch number
|
||||
|
||||
**Problem**: Integration tests fail in CI with "unauthorized"
|
||||
**Cause**: n8n test instance credentials expired (infrastructure issue)
|
||||
**Solution**: Ignore if unit tests pass - this is not a code problem
|
||||
|
||||
**Problem**: CI takes 8+ minutes
|
||||
**Reason**: Integration tests need live n8n instance (slow)
|
||||
**Normal**: Unit tests (~2 min) + integration tests (~6 min) = ~8 min total
|
||||
|
||||
## Quick One-Command Update
|
||||
|
||||
For a complete update with tests and publish preparation:
|
||||
@@ -99,12 +181,14 @@ This command:
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Always run on main branch** - Make sure you're on main and it's clean
|
||||
2. **The update script is smart** - It automatically syncs all n8n dependencies to compatible versions
|
||||
3. **Tests are required** - The publish script now runs tests automatically
|
||||
4. **Database rebuild is automatic** - The update script handles this for you
|
||||
5. **Template sanitization is automatic** - Any API tokens in workflow templates are replaced with placeholders
|
||||
6. **Docker image builds automatically** - Pushing to GitHub triggers the workflow
|
||||
1. **ALWAYS check existing releases first** - Use `gh release list` to see what versions are already released. Your new version must be higher!
|
||||
2. **Release workflow only triggers on version CHANGE** - If you merge a PR with an already-released version (e.g., 2.22.8), the workflow won't run. You'll need to bump to a new version (e.g., 2.22.9) and create another PR.
|
||||
3. **Integration test failures in CI are usually infrastructure issues** - If unit tests pass but integration tests fail with "unauthorized", this is typically because the test n8n instance credentials need updating. The code itself is fine.
|
||||
4. **Skip local tests - let CI handle them** - Running tests locally adds 2-3 minutes with no benefit since CI runs them anyway. The fast workflow skips local tests.
|
||||
5. **The update script is smart** - It automatically syncs all n8n dependencies to compatible versions
|
||||
6. **Database rebuild is automatic** - The update script handles this for you
|
||||
7. **Template sanitization is automatic** - Any API tokens in workflow templates are replaced with placeholders
|
||||
8. **Docker image builds automatically** - Pushing to GitHub triggers the workflow
|
||||
|
||||
## GitHub Push Protection
|
||||
|
||||
@@ -115,11 +199,27 @@ As of July 2025, GitHub's push protection may block database pushes if they cont
|
||||
3. If push is still blocked, use the GitHub web interface to review and allow the push
|
||||
|
||||
## Time Estimate
|
||||
|
||||
### Fast Workflow (Recommended)
|
||||
- Local work: ~2-3 minutes
|
||||
- npm install and database rebuild: ~2-3 minutes
|
||||
- File edits (CHANGELOG, README, package.json): ~30 seconds
|
||||
- Git operations (commit, push, create PR): ~30 seconds
|
||||
- CI testing after PR creation: ~8-10 minutes (runs automatically)
|
||||
- Unit tests: ~2 minutes
|
||||
- Integration tests: ~6 minutes (may fail with infrastructure issues - ignore if unit tests pass)
|
||||
- Other checks: ~1 minute
|
||||
|
||||
**Total hands-on time: ~3 minutes** (then wait for CI)
|
||||
|
||||
### Full Workflow with Local Tests
|
||||
- Total time: ~5-7 minutes
|
||||
- Test suite: ~2.5 minutes
|
||||
- npm install and database rebuild: ~2-3 minutes
|
||||
- The rest: seconds
|
||||
|
||||
**Note**: The fast workflow is recommended since CI runs the same tests anyway.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If tests fail:
|
||||
|
||||
@@ -54,6 +54,10 @@ Collected data is used solely to:
|
||||
- Identify common error patterns
|
||||
- Improve tool performance and reliability
|
||||
- Guide development priorities
|
||||
- Train machine learning models for workflow generation
|
||||
|
||||
All ML training uses sanitized, anonymized data only.
|
||||
Users can opt-out at any time with `npx n8n-mcp telemetry disable`
|
||||
|
||||
## Data Retention
|
||||
- Data is retained for analysis purposes
|
||||
@@ -66,4 +70,4 @@ We may update this privacy policy from time to time. Updates will be reflected i
|
||||
For questions about telemetry or privacy, please open an issue on GitHub:
|
||||
https://github.com/czlonkowski/n8n-mcp/issues
|
||||
|
||||
Last updated: 2025-09-25
|
||||
Last updated: 2025-11-06
|
||||
283
README.md
283
README.md
@@ -5,23 +5,23 @@
|
||||
[](https://www.npmjs.com/package/n8n-mcp)
|
||||
[](https://codecov.io/gh/czlonkowski/n8n-mcp)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/actions)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
|
||||
[](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
|
||||
|
||||
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 525+ workflow automation nodes.
|
||||
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 543 workflow automation nodes.
|
||||
|
||||
## Overview
|
||||
|
||||
n8n-MCP serves as a bridge between n8n's workflow automation platform and AI models, enabling them to understand and work with n8n nodes effectively. It provides structured access to:
|
||||
|
||||
- 📚 **536 n8n nodes** from both n8n-nodes-base and @n8n/n8n-nodes-langchain
|
||||
- 📚 **543 n8n nodes** from both n8n-nodes-base and @n8n/n8n-nodes-langchain
|
||||
- 🔧 **Node properties** - 99% coverage with detailed schemas
|
||||
- ⚡ **Node operations** - 63.6% coverage of available actions
|
||||
- 📄 **Documentation** - 90% coverage from official n8n docs (including AI nodes)
|
||||
- 🤖 **AI tools** - 263 AI-capable nodes detected with full documentation
|
||||
- 📄 **Documentation** - 87% coverage from official n8n docs (including AI nodes)
|
||||
- 🤖 **AI tools** - 271 AI-capable nodes detected with full documentation
|
||||
- 💡 **Real-world examples** - 2,646 pre-extracted configurations from popular templates
|
||||
- 🎯 **Template library** - 2,500+ workflow templates with smart filtering
|
||||
- 🎯 **Template library** - 2,709 workflow templates with 100% metadata coverage
|
||||
|
||||
|
||||
## ⚠️ Important Safety Warning
|
||||
@@ -51,6 +51,8 @@ npx n8n-mcp
|
||||
|
||||
Add to Claude Desktop config:
|
||||
|
||||
> ⚠️ **Important**: The `MCP_MODE: "stdio"` environment variable is **required** for Claude Desktop. Without it, you will see JSON parsing errors like `"Unexpected token..."` in the UI. This variable ensures that only JSON-RPC messages are sent to stdout, preventing debug logs from interfering with the protocol.
|
||||
|
||||
**Basic configuration (documentation tools only):**
|
||||
```json
|
||||
{
|
||||
@@ -284,6 +286,86 @@ environment:
|
||||
N8N_MCP_TELEMETRY_DISABLED: "true"
|
||||
```
|
||||
|
||||
## ⚙️ Database & Memory Configuration
|
||||
|
||||
### Database Adapters
|
||||
|
||||
n8n-mcp uses SQLite for storing node documentation. Two adapters are available:
|
||||
|
||||
1. **better-sqlite3** (Default in Docker)
|
||||
- Native C++ bindings for best performance
|
||||
- Direct disk writes (no memory overhead)
|
||||
- **Now enabled by default** in Docker images (v2.20.2+)
|
||||
- Memory usage: ~100-120 MB stable
|
||||
|
||||
2. **sql.js** (Fallback)
|
||||
- Pure JavaScript implementation
|
||||
- In-memory database with periodic saves
|
||||
- Used when better-sqlite3 compilation fails
|
||||
- Memory usage: ~150-200 MB stable
|
||||
|
||||
### Memory Optimization (sql.js)
|
||||
|
||||
If using sql.js fallback, you can configure the save interval to balance between data safety and memory efficiency:
|
||||
|
||||
**Environment Variable:**
|
||||
```bash
|
||||
SQLJS_SAVE_INTERVAL_MS=5000 # Default: 5000ms (5 seconds)
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
- Controls how long to wait after database changes before saving to disk
|
||||
- Lower values = more frequent saves = higher memory churn
|
||||
- Higher values = less frequent saves = lower memory usage
|
||||
- Minimum: 100ms
|
||||
- Recommended: 5000-10000ms for production
|
||||
|
||||
**Docker Configuration:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"n8n-mcp": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"--init",
|
||||
"-e", "SQLJS_SAVE_INTERVAL_MS=10000",
|
||||
"ghcr.io/czlonkowski/n8n-mcp:latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**docker-compose:**
|
||||
```yaml
|
||||
environment:
|
||||
SQLJS_SAVE_INTERVAL_MS: "10000"
|
||||
```
|
||||
|
||||
### Memory Leak Fix (v2.20.2)
|
||||
|
||||
**Issue #330** identified a critical memory leak in long-running Docker/Kubernetes deployments:
|
||||
- **Before:** 100 MB → 2.2 GB over 72 hours (OOM kills)
|
||||
- **After:** Stable at 100-200 MB indefinitely
|
||||
|
||||
**Fixes Applied:**
|
||||
- ✅ Docker images now use better-sqlite3 by default (eliminates leak entirely)
|
||||
- ✅ sql.js fallback optimized (98% reduction in save frequency)
|
||||
- ✅ Removed unnecessary memory allocations (50% reduction per save)
|
||||
- ✅ Configurable save interval via `SQLJS_SAVE_INTERVAL_MS`
|
||||
|
||||
For Kubernetes deployments with memory limits:
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
```
|
||||
|
||||
## 💖 Support This Project
|
||||
|
||||
<div align="center">
|
||||
@@ -421,6 +503,14 @@ Complete guide for integrating n8n-MCP with Windsurf using project rules.
|
||||
### [Codex](./docs/CODEX_SETUP.md)
|
||||
Complete guide for integrating n8n-MCP with Codex.
|
||||
|
||||
## 🎓 Add Claude Skills (Optional)
|
||||
|
||||
Supercharge your n8n workflow building with specialized skills that teach AI how to build production-ready workflows!
|
||||
|
||||
[](https://www.youtube.com/watch?v=e6VvRqmUY2Y)
|
||||
|
||||
Learn more: [n8n-skills repository](https://github.com/czlonkowski/n8n-skills)
|
||||
|
||||
## 🤖 Claude Project Setup
|
||||
|
||||
For the best results when using n8n-MCP with Claude Projects, use these enhanced system instructions:
|
||||
@@ -443,7 +533,7 @@ When operations are independent, execute them in parallel for maximum performanc
|
||||
❌ BAD: Sequential tool calls (await each one before the next)
|
||||
|
||||
### 3. Templates First
|
||||
ALWAYS check templates before building from scratch (2,500+ available).
|
||||
ALWAYS check templates before building from scratch (2,709 available).
|
||||
|
||||
### 4. Multi-Level Validation
|
||||
Use validate_node_minimal → validate_node_operation → validate_workflow pattern.
|
||||
@@ -475,7 +565,9 @@ ALWAYS explicitly configure ALL parameters that control node behavior.
|
||||
- `list_ai_tools()` - AI-capable nodes
|
||||
|
||||
4. **Configuration Phase** (parallel for multiple nodes)
|
||||
- `get_node_essentials(nodeType, {includeExamples: true})` - 10-20 key properties
|
||||
- `get_node(nodeType, {detail: 'standard', includeExamples: true})` - Essential properties (default)
|
||||
- `get_node(nodeType, {detail: 'minimal'})` - Basic metadata only (~200 tokens)
|
||||
- `get_node(nodeType, {detail: 'full'})` - Complete information (~3000-8000 tokens)
|
||||
- `search_node_properties(nodeType, 'auth')` - Find specific properties
|
||||
- `get_node_documentation(nodeType)` - Human-readable docs
|
||||
- Show workflow architecture to user for approval before proceeding
|
||||
@@ -522,7 +614,7 @@ Default values cause runtime failures. Example:
|
||||
### ⚠️ Example Availability
|
||||
`includeExamples: true` returns real configurations from workflow templates.
|
||||
- Coverage varies by node popularity
|
||||
- When no examples available, use `get_node_essentials` + `validate_node_minimal`
|
||||
- When no examples available, use `get_node` + `validate_node_minimal`
|
||||
|
||||
## Validation Strategy
|
||||
|
||||
@@ -586,6 +678,97 @@ n8n_update_partial_workflow({id: "wf-123", operations: [{...}]})
|
||||
n8n_update_partial_workflow({id: "wf-123", operations: [{...}]})
|
||||
```
|
||||
|
||||
### CRITICAL: addConnection Syntax
|
||||
|
||||
The `addConnection` operation requires **four separate string parameters**. Common mistakes cause misleading errors.
|
||||
|
||||
❌ WRONG - Object format (fails with "Expected string, received object"):
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"connection": {
|
||||
"source": {"nodeId": "node-1", "outputIndex": 0},
|
||||
"destination": {"nodeId": "node-2", "inputIndex": 0}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
❌ WRONG - Combined string (fails with "Source node not found"):
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "node-1:main:0",
|
||||
"target": "node-2:main:0"
|
||||
}
|
||||
```
|
||||
|
||||
✅ CORRECT - Four separate string parameters:
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "node-id-string",
|
||||
"target": "target-node-id-string",
|
||||
"sourcePort": "main",
|
||||
"targetPort": "main"
|
||||
}
|
||||
```
|
||||
|
||||
**Reference**: [GitHub Issue #327](https://github.com/czlonkowski/n8n-mcp/issues/327)
|
||||
|
||||
### ⚠️ CRITICAL: IF Node Multi-Output Routing
|
||||
|
||||
IF nodes have **two outputs** (TRUE and FALSE). Use the **`branch` parameter** to route to the correct output:
|
||||
|
||||
✅ CORRECT - Route to TRUE branch (when condition is met):
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "if-node-id",
|
||||
"target": "success-handler-id",
|
||||
"sourcePort": "main",
|
||||
"targetPort": "main",
|
||||
"branch": "true"
|
||||
}
|
||||
```
|
||||
|
||||
✅ CORRECT - Route to FALSE branch (when condition is NOT met):
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "if-node-id",
|
||||
"target": "failure-handler-id",
|
||||
"sourcePort": "main",
|
||||
"targetPort": "main",
|
||||
"branch": "false"
|
||||
}
|
||||
```
|
||||
|
||||
**Common Pattern** - Complete IF node routing:
|
||||
```json
|
||||
n8n_update_partial_workflow({
|
||||
id: "workflow-id",
|
||||
operations: [
|
||||
{type: "addConnection", source: "If Node", target: "True Handler", sourcePort: "main", targetPort: "main", branch: "true"},
|
||||
{type: "addConnection", source: "If Node", target: "False Handler", sourcePort: "main", targetPort: "main", branch: "false"}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
**Note**: Without the `branch` parameter, both connections may end up on the same output, causing logic errors!
|
||||
|
||||
### removeConnection Syntax
|
||||
|
||||
Use the same four-parameter format:
|
||||
```json
|
||||
{
|
||||
"type": "removeConnection",
|
||||
"source": "source-node-id",
|
||||
"target": "target-node-id",
|
||||
"sourcePort": "main",
|
||||
"targetPort": "main"
|
||||
}
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
### Template-First Approach
|
||||
@@ -621,8 +804,8 @@ list_nodes({category: 'communication'})
|
||||
|
||||
// STEP 2: Configuration (parallel execution)
|
||||
[Silent execution]
|
||||
get_node_essentials('n8n-nodes-base.slack', {includeExamples: true})
|
||||
get_node_essentials('n8n-nodes-base.webhook', {includeExamples: true})
|
||||
get_node('n8n-nodes-base.slack', {detail: 'standard', includeExamples: true})
|
||||
get_node('n8n-nodes-base.webhook', {detail: 'standard', includeExamples: true})
|
||||
|
||||
// STEP 3: Validation (parallel execution)
|
||||
[Silent execution]
|
||||
@@ -661,7 +844,7 @@ n8n_update_partial_workflow({
|
||||
### Core Behavior
|
||||
1. **Silent execution** - No commentary between tools
|
||||
2. **Parallel by default** - Execute independent operations simultaneously
|
||||
3. **Templates first** - Always check before building (2,500+ available)
|
||||
3. **Templates first** - Always check before building (2,709 available)
|
||||
4. **Multi-level validation** - Quick check → Full validation → Workflow validation
|
||||
5. **Never trust defaults** - Explicitly configure ALL parameters
|
||||
|
||||
@@ -679,7 +862,7 @@ n8n_update_partial_workflow({
|
||||
- **Only when necessary** - Use code node as last resort
|
||||
- **AI tool capability** - ANY node can be an AI tool (not just marked ones)
|
||||
|
||||
### Most Popular n8n Nodes (for get_node_essentials):
|
||||
### Most Popular n8n Nodes (for get_node):
|
||||
|
||||
1. **n8n-nodes-base.code** - JavaScript/Python scripting
|
||||
2. **n8n-nodes-base.httpRequest** - HTTP API calls
|
||||
@@ -743,7 +926,7 @@ When Claude, Anthropic's AI assistant, tested n8n-MCP, the results were transfor
|
||||
|
||||
**Without MCP:** "I was basically playing a guessing game. 'Is it `scheduleTrigger` or `schedule`? Does it take `interval` or `rule`?' I'd write what seemed logical, but n8n has its own conventions that you can't just intuit. I made six different configuration errors in a simple HackerNews scraper."
|
||||
|
||||
**With MCP:** "Everything just... worked. Instead of guessing, I could ask `get_node_essentials()` and get exactly what I needed - not a 100KB JSON dump, but the actual 5-10 properties that matter. What took 45 minutes now takes 3 minutes."
|
||||
**With MCP:** "Everything just... worked. Instead of guessing, I could ask `get_node()` and get exactly what I needed - not a 100KB JSON dump, but the actual properties that matter. What took 45 minutes now takes 3 minutes."
|
||||
|
||||
**The Real Value:** "It's about confidence. When you're building automation workflows, uncertainty is expensive. One wrong parameter and your workflow fails at 3 AM. With MCP, I could validate my configuration before deployment. That's not just time saved - that's peace of mind."
|
||||
|
||||
@@ -756,15 +939,21 @@ Once connected, Claude can use these powerful tools:
|
||||
### Core Tools
|
||||
- **`tools_documentation`** - Get documentation for any MCP tool (START HERE!)
|
||||
- **`list_nodes`** - List all n8n nodes with filtering options
|
||||
- **`get_node_info`** - Get comprehensive information about a specific node
|
||||
- **`get_node_essentials`** - Get only essential properties (10-20 instead of 200+). Use `includeExamples: true` to get top 3 real-world configurations from popular templates
|
||||
- **`get_node`** - Unified node information tool with multiple detail levels:
|
||||
- `detail: 'minimal'` - Basic metadata only (~200 tokens)
|
||||
- `detail: 'standard'` - Essential properties (default, ~1000-2000 tokens)
|
||||
- `detail: 'full'` - Complete information (~3000-8000 tokens)
|
||||
- `includeExamples: true` - Include real-world configurations from popular templates
|
||||
- `mode: 'versions'` - View version history and breaking changes
|
||||
- `mode: 'compare'` - Compare two versions with property-level changes
|
||||
- `includeTypeInfo: true` - Add type structure metadata (NEW!)
|
||||
- **`search_nodes`** - Full-text search across all node documentation. Use `includeExamples: true` to get top 2 real-world configurations per node from templates
|
||||
- **`search_node_properties`** - Find specific properties within nodes
|
||||
- **`list_ai_tools`** - List all AI-capable nodes (ANY node can be used as AI tool!)
|
||||
- **`get_node_as_tool_info`** - Get guidance on using any node as an AI tool
|
||||
|
||||
### Template Tools
|
||||
- **`list_templates`** - Browse all templates with descriptions and optional metadata (2,500+ templates)
|
||||
- **`list_templates`** - Browse all templates with descriptions and optional metadata (2,709 templates)
|
||||
- **`search_templates`** - Text search across template names and descriptions
|
||||
- **`search_templates_by_metadata`** - Advanced filtering by complexity, setup time, services, audience
|
||||
- **`list_node_templates`** - Find templates using specific nodes
|
||||
@@ -802,6 +991,7 @@ These powerful tools allow you to manage n8n workflows directly from Claude. The
|
||||
- **`n8n_list_workflows`** - List workflows with filtering and pagination
|
||||
- **`n8n_validate_workflow`** - Validate workflows already in n8n by ID (NEW in v2.6.3)
|
||||
- **`n8n_autofix_workflow`** - Automatically fix common workflow errors (NEW in v2.13.0!)
|
||||
- **`n8n_workflow_versions`** - Manage workflow version history and rollback (NEW in v2.22.0!)
|
||||
|
||||
#### Execution Management
|
||||
- **`n8n_trigger_webhook_workflow`** - Trigger workflows via webhook URL
|
||||
@@ -817,23 +1007,51 @@ These powerful tools allow you to manage n8n workflows directly from Claude. The
|
||||
### Example Usage
|
||||
|
||||
```typescript
|
||||
// Get essentials with real-world examples from templates
|
||||
get_node_essentials({
|
||||
// Get node info with different detail levels
|
||||
get_node({
|
||||
nodeType: "nodes-base.httpRequest",
|
||||
includeExamples: true // Returns top 3 configs from popular templates
|
||||
detail: "standard", // Default: Essential properties
|
||||
includeExamples: true // Include real-world examples from templates
|
||||
})
|
||||
|
||||
// Minimal info for quick reference
|
||||
get_node({
|
||||
nodeType: "nodes-base.slack",
|
||||
detail: "minimal" // ~200 tokens: just basic metadata
|
||||
})
|
||||
|
||||
// Full documentation
|
||||
get_node({
|
||||
nodeType: "nodes-base.webhook",
|
||||
detail: "full", // Complete information
|
||||
includeTypeInfo: true // Include type structure metadata
|
||||
})
|
||||
|
||||
// Version history and breaking changes
|
||||
get_node({
|
||||
nodeType: "nodes-base.httpRequest",
|
||||
mode: "versions" // View all versions with summary
|
||||
})
|
||||
|
||||
// Compare versions
|
||||
get_node({
|
||||
nodeType: "nodes-base.slack",
|
||||
mode: "compare",
|
||||
fromVersion: "2.1",
|
||||
toVersion: "2.2"
|
||||
})
|
||||
|
||||
// Search nodes with configuration examples
|
||||
search_nodes({
|
||||
query: "send email gmail",
|
||||
includeExamples: true // Returns top 2 configs per node
|
||||
includeExamples: true // Returns top 2 configs per node
|
||||
})
|
||||
|
||||
// Validate before deployment
|
||||
validate_node_operation({
|
||||
nodeType: "nodes-base.httpRequest",
|
||||
config: { method: "POST", url: "..." },
|
||||
profile: "runtime" // or "minimal", "ai-friendly", "strict"
|
||||
profile: "runtime" // or "minimal", "ai-friendly", "strict"
|
||||
})
|
||||
|
||||
// Quick required field check
|
||||
@@ -918,20 +1136,27 @@ npm run dev:http # HTTP dev mode
|
||||
|
||||
## 📊 Metrics & Coverage
|
||||
|
||||
Current database coverage (n8n v1.113.3):
|
||||
Current database coverage (n8n v1.117.2):
|
||||
|
||||
- ✅ **536/536** nodes loaded (100%)
|
||||
- ✅ **528** nodes with properties (98.7%)
|
||||
- ✅ **470** nodes with documentation (88%)
|
||||
- ✅ **267** AI-capable tools detected
|
||||
- ✅ **541/541** nodes loaded (100%)
|
||||
- ✅ **541** nodes with properties (100%)
|
||||
- ✅ **470** nodes with documentation (87%)
|
||||
- ✅ **271** AI-capable tools detected
|
||||
- ✅ **2,646** pre-extracted template configurations
|
||||
- ✅ **2,500+** workflow templates available
|
||||
- ✅ **2,709** workflow templates available (100% metadata coverage)
|
||||
- ✅ **AI Agent & LangChain nodes** fully documented
|
||||
- ⚡ **Average response time**: ~12ms
|
||||
- 💾 **Database size**: ~15MB (optimized)
|
||||
- 💾 **Database size**: ~68MB (includes templates with metadata)
|
||||
|
||||
## 🔄 Recent Updates
|
||||
|
||||
### v2.22.19 - Critical Bug Fix
|
||||
**Fixed:** Stack overflow in session removal (Issue #427)
|
||||
- Eliminated infinite recursion in HTTP server session cleanup
|
||||
- Transport resources now deleted before closing to prevent circular event handler chain
|
||||
- Production logs no longer show "RangeError: Maximum call stack size exceeded"
|
||||
- All session cleanup operations now complete successfully without crashes
|
||||
|
||||
See [CHANGELOG.md](./docs/CHANGELOG.md) for full version history and recent changes.
|
||||
|
||||
## ⚠️ Known Issues
|
||||
|
||||
318
README_ANALYSIS.md
Normal file
318
README_ANALYSIS.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# N8N-MCP Validation Analysis: Complete Report
|
||||
|
||||
**Date**: November 8, 2025
|
||||
**Dataset**: 29,218 validation events | 9,021 unique users | 90 days
|
||||
**Status**: Complete and ready for action
|
||||
|
||||
---
|
||||
|
||||
## Analysis Documents
|
||||
|
||||
### 1. ANALYSIS_QUICK_REFERENCE.md (5.8KB)
|
||||
**Best for**: Quick decisions, meetings, slide presentations
|
||||
|
||||
START HERE if you want the key points in 5 minutes.
|
||||
|
||||
**Contains**:
|
||||
- One-paragraph core finding
|
||||
- Top 3 problem areas with root causes
|
||||
- 5 most common errors
|
||||
- Implementation plan summary
|
||||
- Key metrics & targets
|
||||
- FAQ section
|
||||
|
||||
---
|
||||
|
||||
### 2. VALIDATION_ANALYSIS_SUMMARY.md (13KB)
|
||||
**Best for**: Executive stakeholders, team leads, decision makers
|
||||
|
||||
Read this for comprehensive but concise overview.
|
||||
|
||||
**Contains**:
|
||||
- One-page executive summary
|
||||
- Health scorecard with key metrics
|
||||
- Detailed problem area breakdown
|
||||
- Error category distribution
|
||||
- Agent behavior insights
|
||||
- Tool usage patterns
|
||||
- Documentation impact findings
|
||||
- Top 5 recommendations with ROI estimates
|
||||
- 50-65% improvement projection
|
||||
|
||||
---
|
||||
|
||||
### 3. VALIDATION_ANALYSIS_REPORT.md (27KB)
|
||||
**Best for**: Technical deep-dive, implementation planning, root cause analysis
|
||||
|
||||
Complete reference document with all findings.
|
||||
|
||||
**Contains**:
|
||||
- All 16 SQL queries (reproducible)
|
||||
- Node-specific difficulty ranking (top 20)
|
||||
- Top 25 unique validation error messages
|
||||
- Error categorization with root causes
|
||||
- Tool usage patterns before failures
|
||||
- Search query analysis
|
||||
- Documentation effectiveness study
|
||||
- Retry success rate analysis
|
||||
- Property-level difficulty matrix
|
||||
- 8 detailed recommendations with implementation guides
|
||||
- Phase-by-phase action items
|
||||
- KPI tracking setup
|
||||
- Complete appendix with error message reference
|
||||
|
||||
---
|
||||
|
||||
### 4. IMPLEMENTATION_ROADMAP.md (4.3KB)
|
||||
**Best for**: Project managers, development team, sprint planning
|
||||
|
||||
Actionable roadmap for the next 6 weeks.
|
||||
|
||||
**Contains**:
|
||||
- Phase 1-3 breakdown (2 weeks each)
|
||||
- Specific file locations to modify
|
||||
- Effort estimates per task
|
||||
- Success criteria for each phase
|
||||
- Expected impact projections
|
||||
- Code examples (before/after)
|
||||
- Key changes documentation
|
||||
|
||||
---
|
||||
|
||||
## Reading Paths
|
||||
|
||||
### Path A: Decision Maker (30 minutes)
|
||||
1. Read: ANALYSIS_QUICK_REFERENCE.md
|
||||
2. Review: Key metrics in VALIDATION_ANALYSIS_SUMMARY.md
|
||||
3. Decision: Approve IMPLEMENTATION_ROADMAP.md
|
||||
|
||||
### Path B: Product Manager (1 hour)
|
||||
1. Read: VALIDATION_ANALYSIS_SUMMARY.md
|
||||
2. Skim: Top recommendations in VALIDATION_ANALYSIS_REPORT.md
|
||||
3. Review: IMPLEMENTATION_ROADMAP.md
|
||||
4. Check: Success metrics and timelines
|
||||
|
||||
### Path C: Technical Lead (2-3 hours)
|
||||
1. Read: ANALYSIS_QUICK_REFERENCE.md
|
||||
2. Deep-dive: VALIDATION_ANALYSIS_REPORT.md
|
||||
3. Study: IMPLEMENTATION_ROADMAP.md
|
||||
4. Review: Code examples and SQL queries
|
||||
5. Plan: Ticket creation and sprint allocation
|
||||
|
||||
### Path D: Developer (3-4 hours)
|
||||
1. Skim: ANALYSIS_QUICK_REFERENCE.md for context
|
||||
2. Read: VALIDATION_ANALYSIS_REPORT.md sections 3-8
|
||||
3. Study: IMPLEMENTATION_ROADMAP.md thoroughly
|
||||
4. Review: All code locations and examples
|
||||
5. Plan: First task implementation
|
||||
|
||||
---
|
||||
|
||||
## Key Findings Overview
|
||||
|
||||
### The Core Insight
|
||||
Validation failures are NOT broken—they're evidence the system works perfectly. 29,218 validation events prevented bad deployments. The challenge is GUIDANCE GAPS that cause first-attempt failures.
|
||||
|
||||
### Success Evidence
|
||||
- 100% same-day error recovery rate
|
||||
- 100% retry success rate
|
||||
- All agents fix errors when given feedback
|
||||
- Zero "unfixable" errors
|
||||
|
||||
### Problem Areas (75% of errors)
|
||||
1. **Workflow structure** (26%) - JSON malformation
|
||||
2. **Connections** (14%) - Unintuitive syntax
|
||||
3. **Required fields** (8%) - Not marked upfront
|
||||
|
||||
### Most Problematic Nodes
|
||||
- Webhook/Trigger (127 failures)
|
||||
- Slack (73 failures)
|
||||
- AI Agent (36 failures)
|
||||
- HTTP Request (31 failures)
|
||||
- OpenAI (35 failures)
|
||||
|
||||
### Solution Strategy
|
||||
- Phase 1: Better error messages + required field markers (25-30% reduction)
|
||||
- Phase 2: Documentation + validation improvements (additional 15-20%)
|
||||
- Phase 3: Advanced features + monitoring (additional 10-15%)
|
||||
- **Target**: 50-65% total failure reduction in 6 weeks
|
||||
|
||||
---
|
||||
|
||||
## Critical Numbers
|
||||
|
||||
```
|
||||
Validation Events ............. 29,218
|
||||
Unique Users .................. 9,021
|
||||
Data Quality .................. 100% (all marked as errors)
|
||||
|
||||
Current Metrics:
|
||||
Error Rate (doc users) ....... 12.6%
|
||||
Error Rate (non-doc users) ... 10.8%
|
||||
First-attempt success ........ ~77%
|
||||
Retry success ................ 100%
|
||||
Same-day recovery ............ 100%
|
||||
|
||||
Target Metrics (after 6 weeks):
|
||||
Error Rate ................... 6-7% (-50%)
|
||||
First-attempt success ........ 85%+
|
||||
Retry success ................ 100%
|
||||
Implementation effort ........ 60-80 hours
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
```
|
||||
Week 1-2: Phase 1 (Error messages, field markers, webhook guide)
|
||||
Expected: 25-30% failure reduction
|
||||
|
||||
Week 3-4: Phase 2 (Enum suggestions, connection guide, AI validation)
|
||||
Expected: Additional 15-20% reduction
|
||||
|
||||
Week 5-6: Phase 3 (Search improvements, fuzzy matching, KPI setup)
|
||||
Expected: Additional 10-15% reduction
|
||||
|
||||
Target: 50-65% total reduction by Week 6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Use These Documents
|
||||
|
||||
### For Review & Approval
|
||||
1. Start with ANALYSIS_QUICK_REFERENCE.md
|
||||
2. Check key metrics in VALIDATION_ANALYSIS_SUMMARY.md
|
||||
3. Review IMPLEMENTATION_ROADMAP.md for feasibility
|
||||
4. Decision: Approve phase 1-3
|
||||
|
||||
### For Team Planning
|
||||
1. Read IMPLEMENTATION_ROADMAP.md
|
||||
2. Create GitHub issues from each task
|
||||
3. Assign based on effort estimates
|
||||
4. Schedule sprints for phase 1-3
|
||||
|
||||
### For Development
|
||||
1. Review specific recommendations in VALIDATION_ANALYSIS_REPORT.md
|
||||
2. Find code locations in IMPLEMENTATION_ROADMAP.md
|
||||
3. Study code examples (before/after)
|
||||
4. Implement and test
|
||||
|
||||
### For Measurement
|
||||
1. Record baseline metrics (current state)
|
||||
2. Deploy Phase 1 and measure impact
|
||||
3. Use KPI queries from VALIDATION_ANALYSIS_REPORT.md
|
||||
4. Adjust strategy based on actual results
|
||||
|
||||
---
|
||||
|
||||
## Key Recommendations (Priority Order)
|
||||
|
||||
### IMMEDIATE (Week 1-2)
|
||||
1. **Enhance error messages** - Add location + examples
|
||||
2. **Mark required fields** - Add "⚠️ REQUIRED" to tools
|
||||
3. **Create webhook guide** - Document configuration rules
|
||||
|
||||
### HIGH (Week 3-4)
|
||||
4. **Add enum suggestions** - Show valid values in errors
|
||||
5. **Create connections guide** - Document syntax + examples
|
||||
6. **Add AI Agent validation** - Detect missing LLM connections
|
||||
|
||||
### MEDIUM (Week 5-6)
|
||||
7. **Improve search results** - Add configuration hints
|
||||
8. **Build fuzzy matcher** - Suggest similar node types
|
||||
9. **Setup KPI tracking** - Monitor improvement
|
||||
|
||||
---
|
||||
|
||||
## Questions & Answers
|
||||
|
||||
**Q: Why so many validation failures?**
|
||||
A: High usage (9,021 users, complex workflows). System is working—preventing bad deployments.
|
||||
|
||||
**Q: Shouldn't we just allow invalid configurations?**
|
||||
A: No, validation prevents 29,218 broken workflows from deploying. We improve guidance instead.
|
||||
|
||||
**Q: Do agents actually learn from errors?**
|
||||
A: Yes, 100% same-day recovery rate proves feedback works perfectly.
|
||||
|
||||
**Q: Can we really reduce failures by 50-65%?**
|
||||
A: Yes, analysis shows these specific improvements target the actual root causes.
|
||||
|
||||
**Q: How long will this take?**
|
||||
A: 60-80 developer-hours across 6 weeks. Can start immediately.
|
||||
|
||||
**Q: What's the biggest win?**
|
||||
A: Marking required fields (378 errors) + better structure messages (1,268 errors).
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **This Week**: Review all documents and get approval
|
||||
2. **Week 1**: Create GitHub issues from IMPLEMENTATION_ROADMAP.md
|
||||
3. **Week 2**: Assign to team, start Phase 1
|
||||
4. **Week 4**: Deploy Phase 1, start Phase 2
|
||||
5. **Week 6**: Deploy Phase 2, start Phase 3
|
||||
6. **Week 8**: Deploy Phase 3, begin monitoring
|
||||
7. **Week 9+**: Review metrics, iterate
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/
|
||||
├── ANALYSIS_QUICK_REFERENCE.md ............ Quick lookup (5.8KB)
|
||||
├── VALIDATION_ANALYSIS_SUMMARY.md ........ Executive summary (13KB)
|
||||
├── VALIDATION_ANALYSIS_REPORT.md ......... Complete analysis (27KB)
|
||||
├── IMPLEMENTATION_ROADMAP.md ............. Action plan (4.3KB)
|
||||
└── README_ANALYSIS.md ................... This file
|
||||
```
|
||||
|
||||
**Total Documentation**: 50KB of analysis, recommendations, and implementation guidance
|
||||
|
||||
---
|
||||
|
||||
## Contact & Support
|
||||
|
||||
For specific questions:
|
||||
- **Why?** → See VALIDATION_ANALYSIS_REPORT.md Section 2-8
|
||||
- **How?** → See IMPLEMENTATION_ROADMAP.md for code locations
|
||||
- **When?** → See IMPLEMENTATION_ROADMAP.md for timeline
|
||||
- **Metrics?** → See VALIDATION_ANALYSIS_SUMMARY.md key metrics section
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Analysis Date | November 8, 2025 |
|
||||
| Data Period | Sept 26 - Nov 8, 2025 (90 days) |
|
||||
| Sample Size | 29,218 validation events |
|
||||
| Users Analyzed | 9,021 unique users |
|
||||
| SQL Queries | 16 comprehensive queries |
|
||||
| Confidence Level | HIGH |
|
||||
| Status | Complete & Ready for Implementation |
|
||||
|
||||
---
|
||||
|
||||
## Analysis Methodology
|
||||
|
||||
1. **Data Collection**: Extracted all validation_details events from PostgreSQL
|
||||
2. **Categorization**: Grouped errors by type, node, and message pattern
|
||||
3. **Pattern Analysis**: Identified root causes for each error category
|
||||
4. **User Behavior**: Tracked tool usage before/after failures
|
||||
5. **Recovery Analysis**: Measured success rates and correction time
|
||||
6. **Recommendation Development**: Mapped solutions to specific problems
|
||||
7. **Impact Projection**: Estimated improvement from each solution
|
||||
8. **Roadmap Creation**: Phased implementation plan with effort estimates
|
||||
|
||||
**Data Quality**: 100% of validation events properly categorized, no data loss or corruption
|
||||
|
||||
---
|
||||
|
||||
**Analysis Complete** | **Ready for Review** | **Awaiting Approval to Proceed**
|
||||
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
@@ -20,19 +20,19 @@ services:
|
||||
image: n8n-mcp:latest
|
||||
container_name: n8n-mcp
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "${PORT:-3000}:${PORT:-3000}"
|
||||
environment:
|
||||
- MCP_MODE=${MCP_MODE:-http}
|
||||
- AUTH_TOKEN=${AUTH_TOKEN}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- PORT=3000
|
||||
- PORT=${PORT:-3000}
|
||||
volumes:
|
||||
# Mount data directory for persistence
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
test: ["CMD", "sh", "-c", "curl -f http://localhost:$${PORT:-3000}/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -37,11 +37,12 @@ services:
|
||||
container_name: n8n-mcp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${MCP_PORT:-3000}:3000"
|
||||
- "${MCP_PORT:-3000}:${MCP_PORT:-3000}"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- N8N_MODE=true
|
||||
- MCP_MODE=http
|
||||
- PORT=${MCP_PORT:-3000}
|
||||
- N8N_API_URL=http://n8n:5678
|
||||
- N8N_API_KEY=${N8N_API_KEY}
|
||||
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
||||
@@ -56,7 +57,7 @@ services:
|
||||
n8n:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
test: ["CMD", "sh", "-c", "curl -f http://localhost:$${MCP_PORT:-3000}/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
|
||||
# Port mapping
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
- "${PORT:-3000}:${PORT:-3000}"
|
||||
|
||||
# Resource limits
|
||||
deploy:
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
|
||||
# Health check
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/health"]
|
||||
test: ["CMD", "sh", "-c", "curl -f http://127.0.0.1:$${PORT:-3000}/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
111
docs/CI_TEST_INFRASTRUCTURE.md
Normal file
111
docs/CI_TEST_INFRASTRUCTURE.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# CI Test Infrastructure - Known Issues
|
||||
|
||||
## Integration Test Failures for External Contributor PRs
|
||||
|
||||
### Issue Summary
|
||||
|
||||
Integration tests fail for external contributor PRs with "No response from n8n server" errors, despite the code changes being correct. This is a **test infrastructure issue**, not a code quality issue.
|
||||
|
||||
### Root Cause
|
||||
|
||||
1. **GitHub Actions Security**: External contributor PRs don't get access to repository secrets (`N8N_API_URL`, `N8N_API_KEY`, etc.)
|
||||
2. **MSW Mock Server**: Mock Service Worker (MSW) is not properly intercepting HTTP requests in the CI environment
|
||||
3. **Test Configuration**: Integration tests expect `http://localhost:3001/mock-api` but the mock server isn't responding
|
||||
|
||||
### Evidence
|
||||
|
||||
From CI logs (PR #343):
|
||||
```
|
||||
[CI-DEBUG] Global setup complete, N8N_API_URL: http://localhost:3001/mock-api
|
||||
❌ No response from n8n server (repeated 60+ times across 20 tests)
|
||||
```
|
||||
|
||||
The tests ARE using the correct mock URL, but MSW isn't intercepting the requests.
|
||||
|
||||
### Why This Happens
|
||||
|
||||
**For External PRs:**
|
||||
- GitHub Actions doesn't expose repository secrets for security reasons
|
||||
- Prevents malicious PRs from exfiltrating secrets
|
||||
- MSW setup runs but requests don't get intercepted in CI
|
||||
|
||||
**Test Configuration:**
|
||||
- `.env.test` line 19: `N8N_API_URL=http://localhost:3001/mock-api`
|
||||
- `.env.test` line 67: `MSW_ENABLED=true`
|
||||
- CI workflow line 75-80: Secrets set but empty for external PRs
|
||||
|
||||
### Impact
|
||||
|
||||
- ✅ **Code Quality**: NOT affected - the actual code changes are correct
|
||||
- ✅ **Local Testing**: Works fine - MSW intercepts requests locally
|
||||
- ❌ **CI for External PRs**: Integration tests fail (infrastructure issue)
|
||||
- ✅ **CI for Internal PRs**: Works fine (has access to secrets)
|
||||
|
||||
### Current Workarounds
|
||||
|
||||
1. **For Maintainers**: Use `--admin` flag to merge despite failing tests when code is verified correct
|
||||
2. **For Contributors**: Run tests locally where MSW works properly
|
||||
3. **For CI**: Unit tests pass (don't require n8n API), integration tests fail
|
||||
|
||||
### Files Affected
|
||||
|
||||
- `tests/integration/setup/integration-setup.ts` - MSW server setup
|
||||
- `tests/setup/msw-setup.ts` - MSW configuration
|
||||
- `tests/mocks/n8n-api/handlers.ts` - Mock request handlers
|
||||
- `.github/workflows/test.yml` - CI configuration
|
||||
- `.env.test` - Test environment configuration
|
||||
|
||||
### Potential Solutions (Not Implemented)
|
||||
|
||||
1. **Separate Unit/Integration Runs**
|
||||
- Run integration tests only for internal PRs
|
||||
- Skip integration tests for external PRs
|
||||
- Rely on unit tests for external PR validation
|
||||
|
||||
2. **MSW CI Debugging**
|
||||
- Add extensive logging to MSW setup
|
||||
- Check if MSW server actually starts in CI
|
||||
- Verify request interception is working
|
||||
|
||||
3. **Mock Server Process**
|
||||
- Start actual HTTP server in CI instead of MSW
|
||||
- More reliable but adds complexity
|
||||
- Would require test infrastructure refactoring
|
||||
|
||||
4. **Public Test Instance**
|
||||
- Use publicly accessible test n8n instance
|
||||
- Exposes test data, security concerns
|
||||
- Would work for external PRs
|
||||
|
||||
### Decision
|
||||
|
||||
**Status**: Documented but not fixed
|
||||
|
||||
**Rationale**:
|
||||
- Integration test infrastructure refactoring is separate concern from code quality
|
||||
- External PRs are relatively rare compared to internal development
|
||||
- Unit tests provide sufficient coverage for most changes
|
||||
- Maintainers can verify integration tests locally before merging
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
**For External Contributor PRs:**
|
||||
1. ✅ Unit tests must pass
|
||||
2. ✅ TypeScript compilation must pass
|
||||
3. ✅ Build must succeed
|
||||
4. ⚠️ Integration test failures are expected (infrastructure issue)
|
||||
5. ✅ Maintainer verifies locally before merge
|
||||
|
||||
**For Internal PRs:**
|
||||
1. ✅ All tests must pass (unit + integration)
|
||||
2. ✅ Full CI validation
|
||||
|
||||
### References
|
||||
|
||||
- PR #343: First occurrence of this issue
|
||||
- PR #345: Documented the infrastructure issue
|
||||
- Issue: External PRs don't get secrets (GitHub Actions security)
|
||||
|
||||
### Last Updated
|
||||
|
||||
2025-10-21 - Documented as part of PR #345 investigation
|
||||
@@ -4,7 +4,9 @@ Connect n8n-MCP to Claude Code CLI for enhanced n8n workflow development from th
|
||||
|
||||
## Quick Setup via CLI
|
||||
|
||||
### Basic configuration (documentation tools only):
|
||||
### Basic configuration (documentation tools only)
|
||||
|
||||
**For Linux, macOS, or Windows (WSL/Git Bash):**
|
||||
```bash
|
||||
claude mcp add n8n-mcp \
|
||||
-e MCP_MODE=stdio \
|
||||
@@ -13,9 +15,21 @@ claude mcp add n8n-mcp \
|
||||
-- npx n8n-mcp
|
||||
```
|
||||
|
||||
**For native Windows PowerShell:**
|
||||
```powershell
|
||||
# Note: The backtick ` is PowerShell's line continuation character.
|
||||
claude mcp add n8n-mcp `
|
||||
'-e MCP_MODE=stdio' `
|
||||
'-e LOG_LEVEL=error' `
|
||||
'-e DISABLE_CONSOLE_OUTPUT=true' `
|
||||
-- npx n8n-mcp
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Full configuration (with n8n management tools):
|
||||
### Full configuration (with n8n management tools)
|
||||
|
||||
**For Linux, macOS, or Windows (WSL/Git Bash):**
|
||||
```bash
|
||||
claude mcp add n8n-mcp \
|
||||
-e MCP_MODE=stdio \
|
||||
@@ -26,6 +40,18 @@ claude mcp add n8n-mcp \
|
||||
-- npx n8n-mcp
|
||||
```
|
||||
|
||||
**For native Windows PowerShell:**
|
||||
```powershell
|
||||
# Note: The backtick ` is PowerShell's line continuation character.
|
||||
claude mcp add n8n-mcp `
|
||||
'-e MCP_MODE=stdio' `
|
||||
'-e LOG_LEVEL=error' `
|
||||
'-e DISABLE_CONSOLE_OUTPUT=true' `
|
||||
'-e N8N_API_URL=https://your-n8n-instance.com' `
|
||||
'-e N8N_API_KEY=your-api-key' `
|
||||
-- npx n8n-mcp
|
||||
```
|
||||
|
||||
Make sure to replace `https://your-n8n-instance.com` with your actual n8n URL and `your-api-key` with your n8n API key.
|
||||
|
||||
## Alternative Setup Methods
|
||||
@@ -80,15 +106,64 @@ Remove the server:
|
||||
claude mcp remove n8n-mcp
|
||||
```
|
||||
|
||||
## 🎓 Add Claude Skills (Optional)
|
||||
|
||||
Supercharge your n8n workflow building with specialized Claude Code skills! The [n8n-skills](https://github.com/czlonkowski/n8n-skills) repository provides 7 complementary skills that teach AI assistants how to build production-ready n8n workflows.
|
||||
|
||||
### What You Get
|
||||
|
||||
- ✅ **n8n Expression Syntax** - Correct {{}} patterns and common mistakes
|
||||
- ✅ **n8n MCP Tools Expert** - How to use n8n-mcp tools effectively
|
||||
- ✅ **n8n Workflow Patterns** - 5 proven architectural patterns
|
||||
- ✅ **n8n Validation Expert** - Interpret and fix validation errors
|
||||
- ✅ **n8n Node Configuration** - Operation-aware setup guidance
|
||||
- ✅ **n8n Code JavaScript** - Write effective JavaScript in Code nodes
|
||||
- ✅ **n8n Code Python** - Python patterns with limitation awareness
|
||||
|
||||
### Installation
|
||||
|
||||
**Method 1: Plugin Installation** (Recommended)
|
||||
```bash
|
||||
/plugin install czlonkowski/n8n-skills
|
||||
```
|
||||
|
||||
**Method 2: Via Marketplace**
|
||||
```bash
|
||||
# Add as marketplace, then browse and install
|
||||
/plugin marketplace add czlonkowski/n8n-skills
|
||||
|
||||
# Then browse available plugins
|
||||
/plugin install
|
||||
# Select "n8n-mcp-skills" from the list
|
||||
```
|
||||
|
||||
**Method 3: Manual Installation**
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/czlonkowski/n8n-skills.git
|
||||
|
||||
# 2. Copy skills to your Claude Code skills directory
|
||||
cp -r n8n-skills/skills/* ~/.claude/skills/
|
||||
|
||||
# 3. Reload Claude Code
|
||||
# Skills will activate automatically
|
||||
```
|
||||
|
||||
For complete installation instructions, configuration options, and usage examples, see the [n8n-skills README](https://github.com/czlonkowski/n8n-skills#-installation).
|
||||
|
||||
Skills work seamlessly with n8n-mcp to provide expert guidance throughout the workflow building process!
|
||||
|
||||
## Project Instructions
|
||||
|
||||
For optimal results, create a `CLAUDE.md` file in your project root with the instructions from the [main README's Claude Project Setup section](../README.md#-claude-project-setup).
|
||||
|
||||
## Tips
|
||||
|
||||
- If you're running n8n locally, use `http://localhost:5678` as the N8N_API_URL
|
||||
- The n8n API credentials are optional - without them, you'll have documentation and validation tools only
|
||||
- With API credentials, you'll get full workflow management capabilities
|
||||
- Use `--scope local` (default) to keep your API credentials private
|
||||
- Use `--scope project` to share configuration with your team (put credentials in environment variables)
|
||||
- Claude Code will automatically start the MCP server when you begin a conversation
|
||||
- If you're running n8n locally, use `http://localhost:5678` as the `N8N_API_URL`.
|
||||
- The n8n API credentials are optional. Without them, you'll only have access to documentation and validation tools. With credentials, you get full workflow management capabilities.
|
||||
- **Scope Management:**
|
||||
- By default, `claude mcp add` uses `--scope local` (also called "user scope"), which saves the configuration to your global user settings and keeps API keys private.
|
||||
- To share the configuration with your team, use `--scope project`. This saves the configuration to a `.mcp.json` file in your project's root directory.
|
||||
- **Switching Scope:** The cleanest method is to `remove` the server and then `add` it back with the desired scope flag (e.g., `claude mcp remove n8n-mcp` followed by `claude mcp add n8n-mcp --scope project`).
|
||||
- **Manual Switching (Advanced):** You can manually edit your `.claude.json` file (e.g., `C:\Users\YourName\.claude.json`). To switch, cut the `"n8n-mcp": { ... }` block from the top-level `"mcpServers"` object (user scope) and paste it into the nested `"mcpServers"` object under your project's path key (project scope), or vice versa. **Important:** You may need to restart Claude Code for manual changes to take effect.
|
||||
- Claude Code will automatically start the MCP server when you begin a conversation.
|
||||
|
||||
@@ -59,10 +59,10 @@ docker compose up -d
|
||||
- n8n-mcp-data:/app/data
|
||||
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
|
||||
- "${PORT:-3000}:${PORT:-3000}"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/health"]
|
||||
test: ["CMD", "sh", "-c", "curl -f http://127.0.0.1:$${PORT:-3000}/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
724
docs/LIBRARY_USAGE.md
Normal file
724
docs/LIBRARY_USAGE.md
Normal file
@@ -0,0 +1,724 @@
|
||||
# Library Usage Guide - Multi-Tenant / Hosted Deployments
|
||||
|
||||
This guide covers using n8n-mcp as a library dependency for building multi-tenant hosted services.
|
||||
|
||||
## Overview
|
||||
|
||||
n8n-mcp can be used as a Node.js library to build multi-tenant backends that provide MCP services to multiple users or instances. The package exports all necessary components for integration into your existing services.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install n8n-mcp
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Library Mode vs CLI Mode
|
||||
|
||||
- **CLI Mode** (default): Single-player usage via `npx n8n-mcp` or Docker
|
||||
- **Library Mode**: Multi-tenant usage by importing and using the `N8NMCPEngine` class
|
||||
|
||||
### Instance Context
|
||||
|
||||
The `InstanceContext` type allows you to pass per-request configuration to the MCP engine:
|
||||
|
||||
```typescript
|
||||
interface InstanceContext {
|
||||
// Instance-specific n8n API configuration
|
||||
n8nApiUrl?: string;
|
||||
n8nApiKey?: string;
|
||||
n8nApiTimeout?: number;
|
||||
n8nApiMaxRetries?: number;
|
||||
|
||||
// Instance identification
|
||||
instanceId?: string;
|
||||
sessionId?: string;
|
||||
|
||||
// Extensible metadata
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Example
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import { N8NMCPEngine } from 'n8n-mcp';
|
||||
|
||||
const app = express();
|
||||
const mcpEngine = new N8NMCPEngine({
|
||||
sessionTimeout: 3600000, // 1 hour
|
||||
logLevel: 'info'
|
||||
});
|
||||
|
||||
// Handle MCP requests with per-user context
|
||||
app.post('/mcp', async (req, res) => {
|
||||
const instanceContext = {
|
||||
n8nApiUrl: req.user.n8nUrl,
|
||||
n8nApiKey: req.user.n8nApiKey,
|
||||
instanceId: req.user.id
|
||||
};
|
||||
|
||||
await mcpEngine.processRequest(req, res, instanceContext);
|
||||
});
|
||||
|
||||
app.listen(3000);
|
||||
```
|
||||
|
||||
## Multi-Tenant Backend Example
|
||||
|
||||
This example shows a complete multi-tenant implementation with user authentication and instance management:
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import { N8NMCPEngine, InstanceContext, validateInstanceContext } from 'n8n-mcp';
|
||||
|
||||
const app = express();
|
||||
const mcpEngine = new N8NMCPEngine({
|
||||
sessionTimeout: 3600000, // 1 hour
|
||||
logLevel: 'info'
|
||||
});
|
||||
|
||||
// Start MCP engine
|
||||
await mcpEngine.start();
|
||||
|
||||
// Authentication middleware
|
||||
const authenticate = async (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Verify token and attach user to request
|
||||
req.user = await getUserFromToken(token);
|
||||
next();
|
||||
};
|
||||
|
||||
// Get instance configuration from database
|
||||
const getInstanceConfig = async (instanceId: string, userId: string) => {
|
||||
// Your database logic here
|
||||
const instance = await db.instances.findOne({
|
||||
where: { id: instanceId, userId }
|
||||
});
|
||||
|
||||
if (!instance) {
|
||||
throw new Error('Instance not found');
|
||||
}
|
||||
|
||||
return {
|
||||
n8nApiUrl: instance.n8nUrl,
|
||||
n8nApiKey: await decryptApiKey(instance.encryptedApiKey),
|
||||
instanceId: instance.id
|
||||
};
|
||||
};
|
||||
|
||||
// MCP endpoint with per-instance context
|
||||
app.post('/api/instances/:instanceId/mcp', authenticate, async (req, res) => {
|
||||
try {
|
||||
// Get instance configuration
|
||||
const instance = await getInstanceConfig(req.params.instanceId, req.user.id);
|
||||
|
||||
// Create instance context
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: instance.n8nApiUrl,
|
||||
n8nApiKey: instance.n8nApiKey,
|
||||
instanceId: instance.instanceId,
|
||||
metadata: {
|
||||
userId: req.user.id,
|
||||
userAgent: req.headers['user-agent'],
|
||||
ip: req.ip
|
||||
}
|
||||
};
|
||||
|
||||
// Validate context before processing
|
||||
const validation = validateInstanceContext(context);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid instance configuration',
|
||||
details: validation.errors
|
||||
});
|
||||
}
|
||||
|
||||
// Process request with instance context
|
||||
await mcpEngine.processRequest(req, res, context);
|
||||
|
||||
} catch (error) {
|
||||
console.error('MCP request error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Health endpoint
|
||||
app.get('/health', async (req, res) => {
|
||||
const health = await mcpEngine.healthCheck();
|
||||
res.status(health.status === 'healthy' ? 200 : 503).json(health);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
await mcpEngine.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
app.listen(3000);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### N8NMCPEngine
|
||||
|
||||
#### Constructor
|
||||
|
||||
```typescript
|
||||
new N8NMCPEngine(options?: {
|
||||
sessionTimeout?: number; // Session TTL in ms (default: 1800000 = 30min)
|
||||
logLevel?: 'error' | 'warn' | 'info' | 'debug'; // Default: 'info'
|
||||
})
|
||||
```
|
||||
|
||||
#### Methods
|
||||
|
||||
##### `async processRequest(req, res, context?)`
|
||||
|
||||
Process a single MCP request with optional instance context.
|
||||
|
||||
**Parameters:**
|
||||
- `req`: Express request object
|
||||
- `res`: Express response object
|
||||
- `context` (optional): InstanceContext with per-instance configuration
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://instance1.n8n.cloud',
|
||||
n8nApiKey: 'instance1-key',
|
||||
instanceId: 'tenant-123'
|
||||
};
|
||||
|
||||
await engine.processRequest(req, res, context);
|
||||
```
|
||||
|
||||
##### `async healthCheck()`
|
||||
|
||||
Get engine health status for monitoring.
|
||||
|
||||
**Returns:** `EngineHealth`
|
||||
```typescript
|
||||
{
|
||||
status: 'healthy' | 'unhealthy';
|
||||
uptime: number; // seconds
|
||||
sessionActive: boolean;
|
||||
memoryUsage: {
|
||||
used: number;
|
||||
total: number;
|
||||
unit: string;
|
||||
};
|
||||
version: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
app.get('/health', async (req, res) => {
|
||||
const health = await engine.healthCheck();
|
||||
res.status(health.status === 'healthy' ? 200 : 503).json(health);
|
||||
});
|
||||
```
|
||||
|
||||
##### `getSessionInfo()`
|
||||
|
||||
Get current session information for debugging.
|
||||
|
||||
**Returns:**
|
||||
```typescript
|
||||
{
|
||||
active: boolean;
|
||||
sessionId?: string;
|
||||
age?: number; // milliseconds
|
||||
sessions?: {
|
||||
total: number;
|
||||
active: number;
|
||||
expired: number;
|
||||
max: number;
|
||||
sessionIds: string[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
##### `async start()`
|
||||
|
||||
Start the engine (for standalone mode). Not needed when using `processRequest()` directly.
|
||||
|
||||
##### `async shutdown()`
|
||||
|
||||
Graceful shutdown for service lifecycle management.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
process.on('SIGTERM', async () => {
|
||||
await engine.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
#### InstanceContext
|
||||
|
||||
Configuration for a specific user instance:
|
||||
|
||||
```typescript
|
||||
interface InstanceContext {
|
||||
n8nApiUrl?: string;
|
||||
n8nApiKey?: string;
|
||||
n8nApiTimeout?: number;
|
||||
n8nApiMaxRetries?: number;
|
||||
instanceId?: string;
|
||||
sessionId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Validation Functions
|
||||
|
||||
##### `validateInstanceContext(context: InstanceContext)`
|
||||
|
||||
Validate and sanitize instance context.
|
||||
|
||||
**Returns:**
|
||||
```typescript
|
||||
{
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
import { validateInstanceContext } from 'n8n-mcp';
|
||||
|
||||
const validation = validateInstanceContext(context);
|
||||
if (!validation.valid) {
|
||||
console.error('Invalid context:', validation.errors);
|
||||
}
|
||||
```
|
||||
|
||||
##### `isInstanceContext(obj: any)`
|
||||
|
||||
Type guard to check if an object is a valid InstanceContext.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
import { isInstanceContext } from 'n8n-mcp';
|
||||
|
||||
if (isInstanceContext(req.body.context)) {
|
||||
// TypeScript knows this is InstanceContext
|
||||
await engine.processRequest(req, res, req.body.context);
|
||||
}
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### Session Strategies
|
||||
|
||||
The MCP engine supports flexible session ID formats:
|
||||
|
||||
- **UUIDv4**: Internal n8n-mcp format (default)
|
||||
- **Instance-prefixed**: `instance-{userId}-{hash}-{uuid}` for multi-tenant isolation
|
||||
- **Custom formats**: Any non-empty string for mcp-remote and other proxies
|
||||
|
||||
Session validation happens via transport lookup, not format validation. This ensures compatibility with all MCP clients.
|
||||
|
||||
### Multi-Tenant Configuration
|
||||
|
||||
Set these environment variables for multi-tenant mode:
|
||||
|
||||
```bash
|
||||
# Enable multi-tenant mode
|
||||
ENABLE_MULTI_TENANT=true
|
||||
|
||||
# Session strategy: "instance" (default) or "shared"
|
||||
MULTI_TENANT_SESSION_STRATEGY=instance
|
||||
```
|
||||
|
||||
**Session Strategies:**
|
||||
|
||||
- **instance** (recommended): Each tenant gets isolated sessions
|
||||
- Session ID: `instance-{instanceId}-{configHash}-{uuid}`
|
||||
- Better isolation and security
|
||||
- Easier debugging per tenant
|
||||
|
||||
- **shared**: Multiple tenants share sessions with context switching
|
||||
- More efficient for high tenant count
|
||||
- Requires careful context management
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### API Key Management
|
||||
|
||||
Always encrypt API keys server-side:
|
||||
|
||||
```typescript
|
||||
import { createCipheriv, createDecipheriv } from 'crypto';
|
||||
|
||||
// Encrypt before storing
|
||||
const encryptApiKey = (apiKey: string) => {
|
||||
const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv);
|
||||
return cipher.update(apiKey, 'utf8', 'hex') + cipher.final('hex');
|
||||
};
|
||||
|
||||
// Decrypt before using
|
||||
const decryptApiKey = (encrypted: string) => {
|
||||
const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv);
|
||||
return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8');
|
||||
};
|
||||
|
||||
// Use decrypted key in context
|
||||
const context: InstanceContext = {
|
||||
n8nApiKey: await decryptApiKey(instance.encryptedApiKey),
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### Input Validation
|
||||
|
||||
Always validate instance context before processing:
|
||||
|
||||
```typescript
|
||||
import { validateInstanceContext } from 'n8n-mcp';
|
||||
|
||||
const validation = validateInstanceContext(context);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid context: ${validation.errors?.join(', ')}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Implement rate limiting per tenant:
|
||||
|
||||
```typescript
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 requests per windowMs
|
||||
keyGenerator: (req) => req.user?.id || req.ip
|
||||
});
|
||||
|
||||
app.post('/api/instances/:instanceId/mcp', authenticate, limiter, async (req, res) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Always wrap MCP requests in try-catch blocks:
|
||||
|
||||
```typescript
|
||||
app.post('/api/instances/:instanceId/mcp', authenticate, async (req, res) => {
|
||||
try {
|
||||
const context = await getInstanceConfig(req.params.instanceId, req.user.id);
|
||||
await mcpEngine.processRequest(req, res, context);
|
||||
} catch (error) {
|
||||
console.error('MCP error:', error);
|
||||
|
||||
// Don't leak internal errors to clients
|
||||
if (error.message.includes('not found')) {
|
||||
return res.status(404).json({ error: 'Instance not found' });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
Set up periodic health checks:
|
||||
|
||||
```typescript
|
||||
setInterval(async () => {
|
||||
const health = await mcpEngine.healthCheck();
|
||||
|
||||
if (health.status === 'unhealthy') {
|
||||
console.error('MCP engine unhealthy:', health);
|
||||
// Alert your monitoring system
|
||||
}
|
||||
|
||||
// Log metrics
|
||||
console.log('MCP engine metrics:', {
|
||||
uptime: health.uptime,
|
||||
memory: health.memoryUsage,
|
||||
sessionActive: health.sessionActive
|
||||
});
|
||||
}, 60000); // Every minute
|
||||
```
|
||||
|
||||
### Session Monitoring
|
||||
|
||||
Track active sessions:
|
||||
|
||||
```typescript
|
||||
app.get('/admin/sessions', authenticate, async (req, res) => {
|
||||
if (!req.user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const sessionInfo = mcpEngine.getSessionInfo();
|
||||
res.json(sessionInfo);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Testing
|
||||
|
||||
```typescript
|
||||
import { N8NMCPEngine, InstanceContext } from 'n8n-mcp';
|
||||
|
||||
describe('MCP Engine', () => {
|
||||
let engine: N8NMCPEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
engine = new N8NMCPEngine({ logLevel: 'error' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.shutdown();
|
||||
});
|
||||
|
||||
it('should process request with context', async () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://test.n8n.io',
|
||||
n8nApiKey: 'test-key',
|
||||
instanceId: 'test-instance'
|
||||
};
|
||||
|
||||
const mockReq = createMockRequest();
|
||||
const mockRes = createMockResponse();
|
||||
|
||||
await engine.processRequest(mockReq, mockRes, context);
|
||||
|
||||
expect(mockRes.status).toBe(200);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
```typescript
|
||||
import request from 'supertest';
|
||||
import { createApp } from './app';
|
||||
|
||||
describe('Multi-tenant MCP API', () => {
|
||||
let app;
|
||||
let authToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
authToken = await getTestAuthToken();
|
||||
});
|
||||
|
||||
it('should handle MCP request for instance', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/instances/test-instance/mcp')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {}
|
||||
},
|
||||
id: 1
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.result).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Required for multi-tenant mode
|
||||
ENABLE_MULTI_TENANT=true
|
||||
MULTI_TENANT_SESSION_STRATEGY=instance
|
||||
|
||||
# Optional: Logging
|
||||
LOG_LEVEL=info
|
||||
DISABLE_CONSOLE_OUTPUT=false
|
||||
|
||||
# Optional: Session configuration
|
||||
SESSION_TIMEOUT=1800000 # 30 minutes in milliseconds
|
||||
MAX_SESSIONS=100
|
||||
|
||||
# Optional: Performance
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ENABLE_MULTI_TENANT=true
|
||||
ENV LOG_LEVEL=info
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/server.js"]
|
||||
```
|
||||
|
||||
### Kubernetes Deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: n8n-mcp-backend
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: n8n-mcp-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: n8n-mcp-backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: your-registry/n8n-mcp-backend:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: ENABLE_MULTI_TENANT
|
||||
value: "true"
|
||||
- name: LOG_LEVEL
|
||||
value: "info"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Multi-Tenant SaaS Example
|
||||
|
||||
For a complete implementation example, see:
|
||||
- [n8n-mcp-backend](https://github.com/czlonkowski/n8n-mcp-backend) - Full hosted service implementation
|
||||
|
||||
### Migration from Single-Player
|
||||
|
||||
If you're migrating from single-player (CLI/Docker) to multi-tenant:
|
||||
|
||||
1. **Keep backward compatibility** - Use environment fallback:
|
||||
```typescript
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: instanceUrl || process.env.N8N_API_URL,
|
||||
n8nApiKey: instanceKey || process.env.N8N_API_KEY,
|
||||
instanceId: instanceId || 'default'
|
||||
};
|
||||
```
|
||||
|
||||
2. **Gradual rollout** - Start with a feature flag:
|
||||
```typescript
|
||||
const isMultiTenant = process.env.ENABLE_MULTI_TENANT === 'true';
|
||||
|
||||
if (isMultiTenant) {
|
||||
const context = await getInstanceConfig(req.params.instanceId);
|
||||
await engine.processRequest(req, res, context);
|
||||
} else {
|
||||
// Legacy single-player mode
|
||||
await engine.processRequest(req, res);
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Module Resolution Errors
|
||||
|
||||
If you see `Cannot find module 'n8n-mcp'`:
|
||||
|
||||
```bash
|
||||
# Clear node_modules and reinstall
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
# Verify package has types field
|
||||
npm info n8n-mcp
|
||||
|
||||
# Check TypeScript can resolve it
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
#### Session ID Validation Errors
|
||||
|
||||
If you see `Invalid session ID format` errors:
|
||||
|
||||
- Ensure you're using n8n-mcp v2.18.9 or later
|
||||
- Session IDs can be any non-empty string
|
||||
- No need to generate UUIDs - use your own format
|
||||
|
||||
#### Memory Leaks
|
||||
|
||||
If memory usage grows over time:
|
||||
|
||||
```typescript
|
||||
// Ensure proper cleanup
|
||||
process.on('SIGTERM', async () => {
|
||||
await engine.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Monitor session count
|
||||
const sessionInfo = engine.getSessionInfo();
|
||||
console.log('Active sessions:', sessionInfo.sessions?.active);
|
||||
```
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [MCP Protocol Specification](https://modelcontextprotocol.io/docs)
|
||||
- [n8n API Documentation](https://docs.n8n.io/api/)
|
||||
- [Express.js Guide](https://expressjs.com/en/guide/routing.html)
|
||||
- [n8n-mcp Main README](../README.md)
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/czlonkowski/n8n-mcp/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/czlonkowski/n8n-mcp/discussions)
|
||||
- **Security**: For security issues, see [SECURITY.md](../SECURITY.md)
|
||||
757
docs/SESSION_PERSISTENCE.md
Normal file
757
docs/SESSION_PERSISTENCE.md
Normal file
@@ -0,0 +1,757 @@
|
||||
# Session Persistence API - Production Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Session Persistence API enables zero-downtime container deployments in multi-tenant n8n-mcp environments. It allows you to export active MCP session state before shutdown and restore it after restart, maintaining session continuity across container lifecycle events.
|
||||
|
||||
**Version:** 2.24.1+
|
||||
**Status:** Production-ready
|
||||
**Use Cases:** Multi-tenant SaaS, Kubernetes deployments, container orchestration, rolling updates
|
||||
|
||||
## Architecture
|
||||
|
||||
### Session State Components
|
||||
|
||||
Each persisted session contains:
|
||||
|
||||
1. **Session Metadata**
|
||||
- `sessionId`: Unique session identifier (UUID v4)
|
||||
- `createdAt`: ISO 8601 timestamp of session creation
|
||||
- `lastAccess`: ISO 8601 timestamp of last activity
|
||||
|
||||
2. **Instance Context**
|
||||
- `n8nApiUrl`: n8n instance API endpoint
|
||||
- `n8nApiKey`: n8n API authentication key (plaintext)
|
||||
- `instanceId`: Optional tenant/instance identifier
|
||||
- `sessionId`: Optional session-specific identifier
|
||||
- `metadata`: Optional custom application data
|
||||
|
||||
3. **Dormant Session Pattern**
|
||||
- Transport and MCP server objects are NOT persisted
|
||||
- Recreated automatically on first request after restore
|
||||
- Reduces memory footprint during restore
|
||||
|
||||
## API Reference
|
||||
|
||||
### N8NMCPEngine.exportSessionState()
|
||||
|
||||
Exports all active session state for persistence before shutdown.
|
||||
|
||||
```typescript
|
||||
exportSessionState(): SessionState[]
|
||||
```
|
||||
|
||||
**Returns:** Array of session state objects containing metadata and credentials
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const sessions = engine.exportSessionState();
|
||||
// sessions = [
|
||||
// {
|
||||
// sessionId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
// metadata: {
|
||||
// createdAt: '2025-11-24T10:30:00.000Z',
|
||||
// lastAccess: '2025-11-24T17:15:32.000Z'
|
||||
// },
|
||||
// context: {
|
||||
// n8nApiUrl: 'https://tenant1.n8n.cloud',
|
||||
// n8nApiKey: 'n8n_api_...',
|
||||
// instanceId: 'tenant-123',
|
||||
// metadata: { userId: 'user-456' }
|
||||
// }
|
||||
// }
|
||||
// ]
|
||||
```
|
||||
|
||||
**Key Behaviors:**
|
||||
- Exports only non-expired sessions (within sessionTimeout)
|
||||
- Detects and warns about duplicate session IDs
|
||||
- Logs security event with session count
|
||||
- Returns empty array if no active sessions
|
||||
|
||||
### N8NMCPEngine.restoreSessionState()
|
||||
|
||||
Restores sessions from previously exported state after container restart.
|
||||
|
||||
```typescript
|
||||
restoreSessionState(sessions: SessionState[]): number
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `sessions`: Array of session state objects from `exportSessionState()`
|
||||
|
||||
**Returns:** Number of sessions successfully restored
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const sessions = await loadFromEncryptedStorage();
|
||||
const count = engine.restoreSessionState(sessions);
|
||||
console.log(`Restored ${count} sessions`);
|
||||
```
|
||||
|
||||
**Key Behaviors:**
|
||||
- Validates session metadata (timestamps, required fields)
|
||||
- Skips expired sessions (age > sessionTimeout)
|
||||
- Skips duplicate sessions (idempotent)
|
||||
- Respects MAX_SESSIONS limit (100 per container)
|
||||
- Recreates transports/servers lazily on first request
|
||||
- Logs security events for restore success/failure
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Critical: Encrypt Before Storage
|
||||
|
||||
**The exported session state contains plaintext n8n API keys.** You MUST encrypt this data before persisting to disk.
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER DO THIS
|
||||
await fs.writeFile('sessions.json', JSON.stringify(sessions));
|
||||
|
||||
// ✅ ALWAYS ENCRYPT
|
||||
const encrypted = await encryptSessionData(sessions, encryptionKey);
|
||||
await saveToSecureStorage(encrypted);
|
||||
```
|
||||
|
||||
### Recommended Encryption Approach
|
||||
|
||||
```typescript
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Encrypt session data using AES-256-GCM
|
||||
*/
|
||||
async function encryptSessionData(
|
||||
sessions: SessionState[],
|
||||
encryptionKey: Buffer
|
||||
): Promise<string> {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, iv);
|
||||
|
||||
const json = JSON.stringify(sessions);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(json, 'utf8'),
|
||||
cipher.final()
|
||||
]);
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Return base64: iv:authTag:encrypted
|
||||
return [
|
||||
iv.toString('base64'),
|
||||
authTag.toString('base64'),
|
||||
encrypted.toString('base64')
|
||||
].join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt session data
|
||||
*/
|
||||
async function decryptSessionData(
|
||||
encryptedData: string,
|
||||
encryptionKey: Buffer
|
||||
): Promise<SessionState[]> {
|
||||
const [ivB64, authTagB64, encryptedB64] = encryptedData.split(':');
|
||||
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const authTag = Buffer.from(authTagB64, 'base64');
|
||||
const encrypted = Buffer.from(encryptedB64, 'base64');
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
return JSON.parse(decrypted.toString('utf8'));
|
||||
}
|
||||
```
|
||||
|
||||
### Key Management
|
||||
|
||||
Store encryption keys securely:
|
||||
- **Kubernetes:** Use Kubernetes Secrets with encryption at rest
|
||||
- **AWS:** Use AWS Secrets Manager or Parameter Store with KMS
|
||||
- **Azure:** Use Azure Key Vault
|
||||
- **GCP:** Use Secret Manager
|
||||
- **Local Dev:** Use environment variables (NEVER commit to git)
|
||||
|
||||
### Security Logging
|
||||
|
||||
All session persistence operations are logged with `[SECURITY]` prefix:
|
||||
|
||||
```
|
||||
[SECURITY] session_export { timestamp, count }
|
||||
[SECURITY] session_restore { timestamp, sessionId, instanceId }
|
||||
[SECURITY] session_restore_failed { timestamp, sessionId, reason }
|
||||
[SECURITY] max_sessions_reached { timestamp, count }
|
||||
```
|
||||
|
||||
Monitor these logs in production for audit trails and security analysis.
|
||||
|
||||
## Implementation Examples
|
||||
|
||||
### 1. Express.js Multi-Tenant Backend
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import { N8NMCPEngine } from 'n8n-mcp';
|
||||
|
||||
const app = express();
|
||||
const engine = new N8NMCPEngine({
|
||||
sessionTimeout: 1800000, // 30 minutes
|
||||
logLevel: 'info'
|
||||
});
|
||||
|
||||
// Startup: Restore sessions from encrypted storage
|
||||
async function startup() {
|
||||
try {
|
||||
const encrypted = await redis.get('mcp:sessions');
|
||||
if (encrypted) {
|
||||
const sessions = await decryptSessionData(
|
||||
encrypted,
|
||||
process.env.ENCRYPTION_KEY
|
||||
);
|
||||
const count = engine.restoreSessionState(sessions);
|
||||
console.log(`Restored ${count} sessions`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown: Export sessions to encrypted storage
|
||||
async function shutdown() {
|
||||
try {
|
||||
const sessions = engine.exportSessionState();
|
||||
const encrypted = await encryptSessionData(
|
||||
sessions,
|
||||
process.env.ENCRYPTION_KEY
|
||||
);
|
||||
await redis.set('mcp:sessions', encrypted, 'EX', 3600); // 1 hour TTL
|
||||
console.log(`Exported ${sessions.length} sessions`);
|
||||
} catch (error) {
|
||||
console.error('Failed to export sessions:', error);
|
||||
}
|
||||
|
||||
await engine.shutdown();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
// Start server
|
||||
await startup();
|
||||
app.listen(3000);
|
||||
```
|
||||
|
||||
### 2. Kubernetes Deployment with Init Container
|
||||
|
||||
**deployment.yaml:**
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: n8n-mcp
|
||||
spec:
|
||||
replicas: 3
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 1
|
||||
maxSurge: 1
|
||||
template:
|
||||
spec:
|
||||
initContainers:
|
||||
- name: restore-sessions
|
||||
image: your-app:latest
|
||||
command: ['/app/restore-sessions.sh']
|
||||
env:
|
||||
- name: ENCRYPTION_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mcp-secrets
|
||||
key: encryption-key
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mcp-secrets
|
||||
key: redis-url
|
||||
volumeMounts:
|
||||
- name: sessions
|
||||
mountPath: /sessions
|
||||
|
||||
containers:
|
||||
- name: mcp-server
|
||||
image: your-app:latest
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ['/app/export-sessions.sh']
|
||||
env:
|
||||
- name: ENCRYPTION_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mcp-secrets
|
||||
key: encryption-key
|
||||
- name: SESSION_TIMEOUT
|
||||
value: "1800000"
|
||||
volumeMounts:
|
||||
- name: sessions
|
||||
mountPath: /sessions
|
||||
|
||||
# Graceful shutdown configuration
|
||||
terminationGracePeriodSeconds: 30
|
||||
|
||||
volumes:
|
||||
- name: sessions
|
||||
emptyDir: {}
|
||||
```
|
||||
|
||||
**restore-sessions.sh:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Restoring sessions from Redis..."
|
||||
|
||||
# Fetch encrypted sessions from Redis
|
||||
ENCRYPTED=$(redis-cli -u "$REDIS_URL" GET "mcp:sessions:${HOSTNAME}")
|
||||
|
||||
if [ -n "$ENCRYPTED" ]; then
|
||||
echo "$ENCRYPTED" > /sessions/encrypted.txt
|
||||
echo "Sessions fetched, will be restored on startup"
|
||||
else
|
||||
echo "No sessions to restore"
|
||||
fi
|
||||
```
|
||||
|
||||
**export-sessions.sh:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Exporting sessions to Redis..."
|
||||
|
||||
# Trigger session export via HTTP endpoint
|
||||
curl -X POST http://localhost:3000/internal/export-sessions
|
||||
|
||||
echo "Sessions exported successfully"
|
||||
```
|
||||
|
||||
### 3. Docker Compose with Redis
|
||||
|
||||
**docker-compose.yml:**
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
n8n-mcp:
|
||||
build: .
|
||||
environment:
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- SESSION_TIMEOUT=1800000
|
||||
depends_on:
|
||||
- redis
|
||||
volumes:
|
||||
- ./data:/data
|
||||
deploy:
|
||||
replicas: 2
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
stop_grace_period: 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
```
|
||||
|
||||
**Application code:**
|
||||
```typescript
|
||||
import { N8NMCPEngine } from 'n8n-mcp';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL);
|
||||
const engine = new N8NMCPEngine();
|
||||
|
||||
// Export endpoint (called by preStop hook)
|
||||
app.post('/internal/export-sessions', async (req, res) => {
|
||||
try {
|
||||
const sessions = engine.exportSessionState();
|
||||
const encrypted = await encryptSessionData(
|
||||
sessions,
|
||||
Buffer.from(process.env.ENCRYPTION_KEY, 'hex')
|
||||
);
|
||||
|
||||
// Store with hostname as key for per-container tracking
|
||||
await redis.set(
|
||||
`mcp:sessions:${os.hostname()}`,
|
||||
encrypted,
|
||||
'EX',
|
||||
3600
|
||||
);
|
||||
|
||||
res.json({ exported: sessions.length });
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
res.status(500).json({ error: 'Export failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Restore on startup
|
||||
async function startup() {
|
||||
const encrypted = await redis.get(`mcp:sessions:${os.hostname()}`);
|
||||
if (encrypted) {
|
||||
const sessions = await decryptSessionData(
|
||||
encrypted,
|
||||
Buffer.from(process.env.ENCRYPTION_KEY, 'hex')
|
||||
);
|
||||
const count = engine.restoreSessionState(sessions);
|
||||
console.log(`Restored ${count} sessions`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Session Timeout Configuration
|
||||
|
||||
Choose appropriate timeout based on use case:
|
||||
|
||||
```typescript
|
||||
const engine = new N8NMCPEngine({
|
||||
sessionTimeout: 1800000 // 30 minutes (recommended default)
|
||||
});
|
||||
|
||||
// Development: 5 minutes
|
||||
sessionTimeout: 300000
|
||||
|
||||
// Production SaaS: 30-60 minutes
|
||||
sessionTimeout: 1800000 - 3600000
|
||||
|
||||
// Long-running workflows: 2-4 hours
|
||||
sessionTimeout: 7200000 - 14400000
|
||||
```
|
||||
|
||||
### 2. Storage Backend Selection
|
||||
|
||||
**Redis (Recommended for Production)**
|
||||
- Fast read/write for session data
|
||||
- TTL support for automatic cleanup
|
||||
- Pub/sub for distributed coordination
|
||||
- Atomic operations for consistency
|
||||
|
||||
**Database (PostgreSQL/MySQL)**
|
||||
- JSONB column for session state
|
||||
- Good for audit requirements
|
||||
- Slower than Redis
|
||||
- Requires periodic cleanup
|
||||
|
||||
**S3/Cloud Storage**
|
||||
- Good for disaster recovery backups
|
||||
- Not suitable for hot session restore
|
||||
- High latency
|
||||
- Good for long-term session archival
|
||||
|
||||
### 3. Monitoring and Alerting
|
||||
|
||||
Monitor these metrics:
|
||||
|
||||
```typescript
|
||||
// Session export metrics
|
||||
const sessions = engine.exportSessionState();
|
||||
metrics.gauge('mcp.sessions.exported', sessions.length);
|
||||
metrics.gauge('mcp.sessions.export_size_kb',
|
||||
JSON.stringify(sessions).length / 1024
|
||||
);
|
||||
|
||||
// Session restore metrics
|
||||
const restored = engine.restoreSessionState(sessions);
|
||||
metrics.gauge('mcp.sessions.restored', restored);
|
||||
metrics.gauge('mcp.sessions.restore_success_rate',
|
||||
restored / sessions.length
|
||||
);
|
||||
|
||||
// Runtime metrics
|
||||
const info = engine.getSessionInfo();
|
||||
metrics.gauge('mcp.sessions.active', info.active ? 1 : 0);
|
||||
metrics.gauge('mcp.sessions.age_seconds', info.age || 0);
|
||||
```
|
||||
|
||||
Alert on:
|
||||
- Export failures (should be rare)
|
||||
- Low restore success rate (<95%)
|
||||
- MAX_SESSIONS limit reached
|
||||
- High session age (potential leaks)
|
||||
|
||||
### 4. Graceful Shutdown Timing
|
||||
|
||||
Ensure sufficient time for session export:
|
||||
|
||||
```typescript
|
||||
// Kubernetes terminationGracePeriodSeconds
|
||||
terminationGracePeriodSeconds: 30 // 30 seconds minimum
|
||||
|
||||
// Docker stop timeout
|
||||
docker run --stop-timeout 30 your-image
|
||||
|
||||
// Process signal handling
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, starting graceful shutdown...');
|
||||
|
||||
// 1. Stop accepting new requests (5s)
|
||||
await server.close();
|
||||
|
||||
// 2. Wait for in-flight requests (10s)
|
||||
await waitForInFlightRequests(10000);
|
||||
|
||||
// 3. Export sessions (5s)
|
||||
const sessions = engine.exportSessionState();
|
||||
await saveEncryptedSessions(sessions);
|
||||
|
||||
// 4. Cleanup (5s)
|
||||
await engine.shutdown();
|
||||
|
||||
// 5. Exit (5s buffer)
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Idempotency Handling
|
||||
|
||||
Sessions can be restored multiple times safely:
|
||||
|
||||
```typescript
|
||||
// First restore
|
||||
const count1 = engine.restoreSessionState(sessions);
|
||||
// count1 = 5
|
||||
|
||||
// Second restore (same sessions)
|
||||
const count2 = engine.restoreSessionState(sessions);
|
||||
// count2 = 0 (all already exist)
|
||||
```
|
||||
|
||||
This is safe for:
|
||||
- Init container retries
|
||||
- Manual recovery operations
|
||||
- Disaster recovery scenarios
|
||||
|
||||
### 6. Multi-Instance Coordination
|
||||
|
||||
For multiple container instances:
|
||||
|
||||
```typescript
|
||||
// Option 1: Per-instance storage (simple)
|
||||
const key = `mcp:sessions:${instance.hostname}`;
|
||||
|
||||
// Option 2: Centralized with distributed lock (advanced)
|
||||
const lock = await acquireLock('mcp:session-export');
|
||||
try {
|
||||
const allSessions = await getAllInstanceSessions();
|
||||
await saveToBackup(allSessions);
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Memory Usage
|
||||
|
||||
```typescript
|
||||
// Each session: ~1-2 KB in memory
|
||||
// 100 sessions: ~100-200 KB
|
||||
// 1000 sessions: ~1-2 MB
|
||||
|
||||
// Export serialized size
|
||||
const sessions = engine.exportSessionState();
|
||||
const sizeKB = JSON.stringify(sessions).length / 1024;
|
||||
console.log(`Export size: ${sizeKB.toFixed(2)} KB`);
|
||||
```
|
||||
|
||||
### Export/Restore Speed
|
||||
|
||||
```typescript
|
||||
// Export: O(n) where n = active sessions
|
||||
// Typical: 50-100 sessions in <10ms
|
||||
|
||||
// Restore: O(n) with validation
|
||||
// Typical: 50-100 sessions in 20-50ms
|
||||
|
||||
// Factor in encryption:
|
||||
// AES-256-GCM: ~1ms per 100 sessions
|
||||
```
|
||||
|
||||
### MAX_SESSIONS Limit
|
||||
|
||||
Hard limit: 100 sessions per container
|
||||
|
||||
```typescript
|
||||
// Restore respects limit
|
||||
const sessions = createSessions(150); // 150 sessions
|
||||
const restored = engine.restoreSessionState(sessions);
|
||||
// restored = 100 (only first 100 restored)
|
||||
```
|
||||
|
||||
For >100 sessions per tenant:
|
||||
- Deploy multiple containers
|
||||
- Use session routing/sharding
|
||||
- Implement session affinity
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: No sessions restored
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
Restored 0 sessions
|
||||
```
|
||||
|
||||
**Causes:**
|
||||
1. All sessions expired (age > sessionTimeout)
|
||||
2. Invalid date format in metadata
|
||||
3. Missing required context fields
|
||||
|
||||
**Debug:**
|
||||
```typescript
|
||||
const sessions = await loadFromEncryptedStorage();
|
||||
console.log('Loaded sessions:', sessions.length);
|
||||
|
||||
// Check individual sessions
|
||||
sessions.forEach((s, i) => {
|
||||
const age = Date.now() - new Date(s.metadata.lastAccess).getTime();
|
||||
console.log(`Session ${i}: age=${age}ms, expired=${age > sessionTimeout}`);
|
||||
});
|
||||
```
|
||||
|
||||
### Issue: Restore fails with "invalid context"
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
[SECURITY] session_restore_failed { sessionId: '...', reason: 'invalid context: ...' }
|
||||
```
|
||||
|
||||
**Causes:**
|
||||
1. Missing n8nApiUrl or n8nApiKey
|
||||
2. Invalid URL format
|
||||
3. Corrupted session data
|
||||
|
||||
**Fix:**
|
||||
```typescript
|
||||
// Validate before restore
|
||||
const valid = sessions.filter(s => {
|
||||
if (!s.context?.n8nApiUrl || !s.context?.n8nApiKey) {
|
||||
console.warn(`Invalid session ${s.sessionId}: missing credentials`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new URL(s.context.n8nApiUrl); // Validate URL
|
||||
return true;
|
||||
} catch {
|
||||
console.warn(`Invalid session ${s.sessionId}: malformed URL`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const count = engine.restoreSessionState(valid);
|
||||
```
|
||||
|
||||
### Issue: MAX_SESSIONS limit hit
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
Reached MAX_SESSIONS limit (100), skipping remaining sessions
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Scale horizontally (more containers)
|
||||
2. Implement session sharding
|
||||
3. Reduce sessionTimeout
|
||||
4. Clean up inactive sessions
|
||||
|
||||
```typescript
|
||||
// Pre-filter by activity
|
||||
const recentSessions = sessions.filter(s => {
|
||||
const age = Date.now() - new Date(s.metadata.lastAccess).getTime();
|
||||
return age < 600000; // Only restore sessions active in last 10 min
|
||||
});
|
||||
|
||||
const count = engine.restoreSessionState(recentSessions);
|
||||
```
|
||||
|
||||
### Issue: Duplicate session IDs
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
Duplicate sessionId detected during export: 550e8400-...
|
||||
```
|
||||
|
||||
**Cause:** Bug in session management logic
|
||||
|
||||
**Fix:** This is a warning, not an error. The duplicate is automatically skipped. If persistent, investigate session creation logic.
|
||||
|
||||
### Issue: High memory usage after restore
|
||||
|
||||
**Symptoms:** Container OOM after restoring many sessions
|
||||
|
||||
**Cause:** Too many sessions for container resources
|
||||
|
||||
**Solution:**
|
||||
```typescript
|
||||
// Restore in batches
|
||||
async function restoreInBatches(sessions: SessionState[], batchSize = 25) {
|
||||
let totalRestored = 0;
|
||||
|
||||
for (let i = 0; i < sessions.length; i += batchSize) {
|
||||
const batch = sessions.slice(i, i + batchSize);
|
||||
const count = engine.restoreSessionState(batch);
|
||||
totalRestored += count;
|
||||
|
||||
// Wait for GC between batches
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
return totalRestored;
|
||||
}
|
||||
```
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
| Feature | Version | Status |
|
||||
|---------|---------|--------|
|
||||
| exportSessionState() | 2.3.0+ | Stable |
|
||||
| restoreSessionState() | 2.3.0+ | Stable |
|
||||
| Security logging | 2.24.1+ | Stable |
|
||||
| Duplicate detection | 2.24.1+ | Stable |
|
||||
| Race condition fix | 2.24.1+ | Stable |
|
||||
| Date validation | 2.24.1+ | Stable |
|
||||
| Optional instanceId | 2.24.1+ | Stable |
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [HTTP Deployment Guide](./HTTP_DEPLOYMENT.md) - Multi-tenant HTTP server setup
|
||||
- [Library Usage Guide](./LIBRARY_USAGE.md) - Embedding n8n-mcp in your app
|
||||
- [Docker Guide](./DOCKER_README.md) - Container deployment
|
||||
- [Flexible Instance Configuration](./FLEXIBLE_INSTANCE_CONFIGURATION.md) - Multi-tenant patterns
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- GitHub Issues: https://github.com/czlonkowski/n8n-mcp/issues
|
||||
- Documentation: https://github.com/czlonkowski/n8n-mcp#readme
|
||||
|
||||
---
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
239
docs/TYPE_STRUCTURE_VALIDATION.md
Normal file
239
docs/TYPE_STRUCTURE_VALIDATION.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Type Structure Validation
|
||||
|
||||
## Overview
|
||||
|
||||
Type Structure Validation is an automatic validation system that ensures complex n8n node configurations conform to their expected data structures. Implemented as part of the n8n-mcp validation system, it provides zero-configuration validation for special n8n types that have complex nested structures.
|
||||
|
||||
**Status:** Production (v2.22.21+)
|
||||
**Performance:** 100% pass rate on 776 real-world validations
|
||||
**Speed:** 0.01ms average validation time (500x faster than target)
|
||||
|
||||
The system automatically validates node configurations without requiring any additional setup or configuration from users or AI assistants.
|
||||
|
||||
## Supported Types
|
||||
|
||||
The validation system supports four special n8n types that have complex structures:
|
||||
|
||||
### 1. **filter** (FilterValue)
|
||||
Complex filtering conditions with boolean operators, comparison operations, and nested logic.
|
||||
|
||||
**Structure:**
|
||||
- `combinator`: "and" | "or" - How conditions are combined
|
||||
- `conditions`: Array of filter conditions
|
||||
- Each condition has: `leftValue`, `operator` (type + operation), `rightValue`
|
||||
- Supports 40+ operations: equals, contains, exists, notExists, gt, lt, regex, etc.
|
||||
|
||||
**Example Usage:** IF node, Switch node condition filtering
|
||||
|
||||
### 2. **resourceMapper** (ResourceMapperValue)
|
||||
Data mapping configuration for transforming data between different formats.
|
||||
|
||||
**Structure:**
|
||||
- `mappingMode`: "defineBelow" | "autoMapInputData" | "mapManually"
|
||||
- `value`: Field mappings or expressions
|
||||
- `matchingColumns`: Column matching configuration
|
||||
- `schema`: Target schema definition
|
||||
|
||||
**Example Usage:** Google Sheets node, Airtable node data mapping
|
||||
|
||||
### 3. **assignmentCollection** (AssignmentCollectionValue)
|
||||
Variable assignments for setting multiple values at once.
|
||||
|
||||
**Structure:**
|
||||
- `assignments`: Array of name-value pairs
|
||||
- Each assignment has: `name`, `value`, `type`
|
||||
|
||||
**Example Usage:** Set node, Code node variable assignments
|
||||
|
||||
### 4. **resourceLocator** (INodeParameterResourceLocator)
|
||||
Resource selection with multiple lookup modes (ID, name, URL, etc.).
|
||||
|
||||
**Structure:**
|
||||
- `mode`: "id" | "list" | "url" | "name"
|
||||
- `value`: Resource identifier (string, number, or expression)
|
||||
- `cachedResultName`: Optional cached display name
|
||||
- `cachedResultUrl`: Optional cached URL
|
||||
|
||||
**Example Usage:** Google Sheets spreadsheet selection, Slack channel selection
|
||||
|
||||
## Performance & Results
|
||||
|
||||
The validation system was tested against real-world n8n.io workflow templates:
|
||||
|
||||
| Metric | Result |
|
||||
|--------|--------|
|
||||
| **Templates Tested** | 91 (top by popularity) |
|
||||
| **Nodes Validated** | 616 nodes with special types |
|
||||
| **Total Validations** | 776 property validations |
|
||||
| **Pass Rate** | 100.00% (776/776) |
|
||||
| **False Positive Rate** | 0.00% |
|
||||
| **Average Time** | 0.01ms per validation |
|
||||
| **Max Time** | 1.00ms per validation |
|
||||
| **Performance vs Target** | 500x faster than 50ms target |
|
||||
|
||||
### Type-Specific Results
|
||||
|
||||
- `filter`: 93/93 passed (100.00%)
|
||||
- `resourceMapper`: 69/69 passed (100.00%)
|
||||
- `assignmentCollection`: 213/213 passed (100.00%)
|
||||
- `resourceLocator`: 401/401 passed (100.00%)
|
||||
|
||||
## How It Works
|
||||
|
||||
### Automatic Integration
|
||||
|
||||
Structure validation is automatically applied during node configuration validation. When you call `validate_node_operation` or `validate_node_minimal`, the system:
|
||||
|
||||
1. **Identifies Special Types**: Detects properties that use filter, resourceMapper, assignmentCollection, or resourceLocator types
|
||||
2. **Validates Structure**: Checks that the configuration matches the expected structure for that type
|
||||
3. **Validates Operations**: For filter types, validates that operations are supported for the data type
|
||||
4. **Provides Context**: Returns specific error messages with property paths and fix suggestions
|
||||
|
||||
### Validation Flow
|
||||
|
||||
```
|
||||
User/AI provides node config
|
||||
↓
|
||||
validate_node_operation (MCP tool)
|
||||
↓
|
||||
EnhancedConfigValidator.validateWithMode()
|
||||
↓
|
||||
validateSpecialTypeStructures() ← Automatic structure validation
|
||||
↓
|
||||
TypeStructureService.validateStructure()
|
||||
↓
|
||||
Returns validation result with errors/warnings/suggestions
|
||||
```
|
||||
|
||||
### Edge Cases Handled
|
||||
|
||||
**1. Credential-Provided Fields**
|
||||
- Fields like Google Sheets `sheetId` that come from n8n credentials at runtime are excluded from validation
|
||||
- No false positives for fields that aren't in the configuration
|
||||
|
||||
**2. Filter Operations**
|
||||
- Universal operations (`exists`, `notExists`, `isNotEmpty`) work across all data types
|
||||
- Type-specific operations validated (e.g., `regex` only for strings, `gt`/`lt` only for numbers)
|
||||
|
||||
**3. Node-Specific Logic**
|
||||
- Custom validation logic for specific nodes (Google Sheets, Slack, etc.)
|
||||
- Context-aware error messages that understand the node's operation
|
||||
|
||||
## Example Validation Error
|
||||
|
||||
### Invalid Filter Structure
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"conditions": {
|
||||
"combinator": "and",
|
||||
"conditions": [
|
||||
{
|
||||
"leftValue": "={{ $json.status }}",
|
||||
"rightValue": "active",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "invalidOperation" // ❌ Not a valid operation
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Validation Error:**
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"errors": [
|
||||
{
|
||||
"type": "invalid_structure",
|
||||
"property": "conditions.conditions[0].operator.operation",
|
||||
"message": "Unsupported operation 'invalidOperation' for type 'string'",
|
||||
"suggestion": "Valid operations for string: equals, notEquals, contains, notContains, startsWith, endsWith, regex, exists, notExists, isNotEmpty"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Implementation
|
||||
|
||||
- **Type Definitions**: `src/types/type-structures.ts` (301 lines)
|
||||
- **Type Structures**: `src/constants/type-structures.ts` (741 lines, 22 complete type structures)
|
||||
- **Service Layer**: `src/services/type-structure-service.ts` (427 lines)
|
||||
- **Validator Integration**: `src/services/enhanced-config-validator.ts` (line 270)
|
||||
- **Node-Specific Logic**: `src/services/node-specific-validators.ts`
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- **Unit Tests**:
|
||||
- `tests/unit/types/type-structures.test.ts` (14 tests)
|
||||
- `tests/unit/constants/type-structures.test.ts` (39 tests)
|
||||
- `tests/unit/services/type-structure-service.test.ts` (64 tests)
|
||||
- `tests/unit/services/enhanced-config-validator-type-structures.test.ts`
|
||||
|
||||
- **Integration Tests**:
|
||||
- `tests/integration/validation/real-world-structure-validation.test.ts` (8 tests, 388ms)
|
||||
|
||||
- **Validation Scripts**:
|
||||
- `scripts/test-structure-validation.ts` - Standalone validation against 100 templates
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Implementation Plan**: `docs/local/v3/implementation-plan-final.md` - Complete technical specifications
|
||||
- **Phase Results**: Phases 1-3 completed with 100% success criteria met
|
||||
|
||||
## For Developers
|
||||
|
||||
### Adding New Type Structures
|
||||
|
||||
1. Define the type structure in `src/constants/type-structures.ts`
|
||||
2. Add validation logic in `TypeStructureService.validateStructure()`
|
||||
3. Add tests in `tests/unit/constants/type-structures.test.ts`
|
||||
4. Test against real templates using `scripts/test-structure-validation.ts`
|
||||
|
||||
### Testing Structure Validation
|
||||
|
||||
**Run Unit Tests:**
|
||||
```bash
|
||||
npm run test:unit -- tests/unit/services/enhanced-config-validator-type-structures.test.ts
|
||||
```
|
||||
|
||||
**Run Integration Tests:**
|
||||
```bash
|
||||
npm run test:integration -- tests/integration/validation/real-world-structure-validation.test.ts
|
||||
```
|
||||
|
||||
**Run Full Validation:**
|
||||
```bash
|
||||
npm run test:structure-validation
|
||||
```
|
||||
|
||||
### Relevant Test Files
|
||||
|
||||
- **Type Tests**: `tests/unit/types/type-structures.test.ts`
|
||||
- **Structure Tests**: `tests/unit/constants/type-structures.test.ts`
|
||||
- **Service Tests**: `tests/unit/services/type-structure-service.test.ts`
|
||||
- **Validator Tests**: `tests/unit/services/enhanced-config-validator-type-structures.test.ts`
|
||||
- **Integration Tests**: `tests/integration/validation/real-world-structure-validation.test.ts`
|
||||
- **Real-World Validation**: `scripts/test-structure-validation.ts`
|
||||
|
||||
## Production Readiness
|
||||
|
||||
✅ **All Tests Passing**: 100% pass rate on unit and integration tests
|
||||
✅ **Performance Validated**: 0.01ms average (500x better than 50ms target)
|
||||
✅ **Zero Breaking Changes**: Fully backward compatible
|
||||
✅ **Real-World Validation**: 91 templates, 616 nodes, 776 validations
|
||||
✅ **Production Deployment**: Successfully deployed in v2.22.21
|
||||
✅ **Edge Cases Handled**: Credential fields, filter operations, node-specific logic
|
||||
|
||||
## Version History
|
||||
|
||||
- **v2.22.21** (2025-11-21): Type structure validation system completed (Phases 1-3)
|
||||
- 22 complete type structures defined
|
||||
- 100% pass rate on real-world validation
|
||||
- 0.01ms average validation time
|
||||
- Zero false positives
|
||||
@@ -162,7 +162,7 @@ n8n_validate_workflow({id: createdWorkflowId})
|
||||
n8n_update_partial_workflow({
|
||||
workflowId: id,
|
||||
operations: [
|
||||
{type: 'updateNode', nodeId: 'slack1', changes: {position: [100, 200]}}
|
||||
{type: 'updateNode', nodeId: 'slack1', updates: {position: [100, 200]}}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
BIN
docs/img/skills.png
Normal file
BIN
docs/img/skills.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 430 KiB |
7197
package-lock.json
generated
7197
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -1,8 +1,16 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.18.6",
|
||||
"version": "2.24.1",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"n8n-mcp": "./dist/mcp/index.js"
|
||||
},
|
||||
@@ -58,6 +66,7 @@
|
||||
"test:workflow-diff": "node dist/scripts/test-workflow-diff.js",
|
||||
"test:transactional-diff": "node dist/scripts/test-transactional-diff.js",
|
||||
"test:tools-documentation": "node dist/scripts/test-tools-documentation.js",
|
||||
"test:structure-validation": "npx tsx scripts/test-structure-validation.ts",
|
||||
"test:url-configuration": "npm run build && ts-node scripts/test-url-configuration.ts",
|
||||
"test:search-improvements": "node dist/scripts/test-search-improvements.js",
|
||||
"test:fts5-search": "node dist/scripts/test-fts5-search.js",
|
||||
@@ -131,18 +140,19 @@
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.13.2",
|
||||
"@n8n/n8n-nodes-langchain": "^1.113.1",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@n8n/n8n-nodes-langchain": "^1.119.1",
|
||||
"@supabase/supabase-js": "^2.57.4",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"lru-cache": "^11.2.1",
|
||||
"n8n": "^1.114.3",
|
||||
"n8n-core": "^1.113.1",
|
||||
"n8n-workflow": "^1.111.0",
|
||||
"n8n": "^1.120.3",
|
||||
"n8n-core": "^1.119.2",
|
||||
"n8n-workflow": "^1.117.0",
|
||||
"openai": "^4.77.0",
|
||||
"sql.js": "^1.13.0",
|
||||
"tslib": "^2.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp-runtime",
|
||||
"version": "2.18.1",
|
||||
"version": "2.23.0",
|
||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
@@ -11,6 +11,7 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"lru-cache": "^11.2.1",
|
||||
"sql.js": "^1.13.0",
|
||||
"tslib": "^2.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
"axios": "^1.7.7"
|
||||
},
|
||||
|
||||
78
scripts/audit-schema-coverage.ts
Normal file
78
scripts/audit-schema-coverage.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Database Schema Coverage Audit Script
|
||||
*
|
||||
* Audits the database to determine how many nodes have complete schema information
|
||||
* for resourceLocator mode validation. This helps assess the coverage of our
|
||||
* schema-driven validation approach.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
||||
const dbPath = path.join(__dirname, '../data/nodes.db');
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
console.log('=== Schema Coverage Audit ===\n');
|
||||
|
||||
// Query 1: How many nodes have resourceLocator properties?
|
||||
const totalResourceLocator = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
`).get() as { count: number };
|
||||
|
||||
console.log(`Nodes with resourceLocator properties: ${totalResourceLocator.count}`);
|
||||
|
||||
// Query 2: Of those, how many have modes defined?
|
||||
const withModes = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
AND properties_schema LIKE '%modes%'
|
||||
`).get() as { count: number };
|
||||
|
||||
console.log(`Nodes with modes defined: ${withModes.count}`);
|
||||
|
||||
// Query 3: Which nodes have resourceLocator but NO modes?
|
||||
const withoutModes = db.prepare(`
|
||||
SELECT node_type, display_name
|
||||
FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
AND properties_schema NOT LIKE '%modes%'
|
||||
LIMIT 10
|
||||
`).all() as Array<{ node_type: string; display_name: string }>;
|
||||
|
||||
console.log(`\nSample nodes WITHOUT modes (showing 10):`);
|
||||
withoutModes.forEach(node => {
|
||||
console.log(` - ${node.display_name} (${node.node_type})`);
|
||||
});
|
||||
|
||||
// Calculate coverage percentage
|
||||
const coverage = totalResourceLocator.count > 0
|
||||
? (withModes.count / totalResourceLocator.count) * 100
|
||||
: 0;
|
||||
|
||||
console.log(`\nSchema coverage: ${coverage.toFixed(1)}% of resourceLocator nodes have modes defined`);
|
||||
|
||||
// Query 4: Get some examples of nodes WITH modes for verification
|
||||
console.log('\nSample nodes WITH modes (showing 5):');
|
||||
const withModesExamples = db.prepare(`
|
||||
SELECT node_type, display_name
|
||||
FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
AND properties_schema LIKE '%modes%'
|
||||
LIMIT 5
|
||||
`).all() as Array<{ node_type: string; display_name: string }>;
|
||||
|
||||
withModesExamples.forEach(node => {
|
||||
console.log(` - ${node.display_name} (${node.node_type})`);
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(`Total nodes in database: ${db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any as { count: number }.count}`);
|
||||
console.log(`Nodes with resourceLocator: ${totalResourceLocator.count}`);
|
||||
console.log(`Nodes with complete mode schemas: ${withModes.count}`);
|
||||
console.log(`Nodes without mode schemas: ${totalResourceLocator.count - withModes.count}`);
|
||||
console.log(`\nImplication: Schema-driven validation will apply to ${withModes.count} nodes.`);
|
||||
console.log(`For the remaining ${totalResourceLocator.count - withModes.count} nodes, validation will be skipped (graceful degradation).`);
|
||||
|
||||
db.close();
|
||||
192
scripts/backfill-mutation-hashes.ts
Normal file
192
scripts/backfill-mutation-hashes.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Backfill script to populate structural hashes for existing workflow mutations
|
||||
*
|
||||
* Purpose: Generates workflow_structure_hash_before and workflow_structure_hash_after
|
||||
* for all existing mutations to enable cross-referencing with telemetry_workflows
|
||||
*
|
||||
* Usage: npx tsx scripts/backfill-mutation-hashes.ts
|
||||
*
|
||||
* Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
*/
|
||||
|
||||
import { WorkflowSanitizer } from '../src/telemetry/workflow-sanitizer.js';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
// Initialize Supabase client
|
||||
const supabaseUrl = process.env.SUPABASE_URL || '';
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
console.error('Error: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables are required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
interface MutationRecord {
|
||||
id: string;
|
||||
workflow_before: any;
|
||||
workflow_after: any;
|
||||
workflow_structure_hash_before: string | null;
|
||||
workflow_structure_hash_after: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all mutations that need structural hashes
|
||||
*/
|
||||
async function fetchMutationsToBackfill(): Promise<MutationRecord[]> {
|
||||
console.log('Fetching mutations without structural hashes...');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('workflow_mutations')
|
||||
.select('id, workflow_before, workflow_after, workflow_structure_hash_before, workflow_structure_hash_after')
|
||||
.is('workflow_structure_hash_before', null);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch mutations: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log(`Found ${data?.length || 0} mutations to backfill`);
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate structural hash for a workflow
|
||||
*/
|
||||
function generateStructuralHash(workflow: any): string {
|
||||
try {
|
||||
return WorkflowSanitizer.generateWorkflowHash(workflow);
|
||||
} catch (error) {
|
||||
console.error('Error generating hash:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single mutation with structural hashes
|
||||
*/
|
||||
async function updateMutation(id: string, structureHashBefore: string, structureHashAfter: string): Promise<boolean> {
|
||||
const { error } = await supabase
|
||||
.from('workflow_mutations')
|
||||
.update({
|
||||
workflow_structure_hash_before: structureHashBefore,
|
||||
workflow_structure_hash_after: structureHashAfter,
|
||||
})
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to update mutation ${id}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process mutations in batches
|
||||
*/
|
||||
async function backfillMutations() {
|
||||
const startTime = Date.now();
|
||||
console.log('Starting backfill process...\n');
|
||||
|
||||
// Fetch mutations
|
||||
const mutations = await fetchMutationsToBackfill();
|
||||
|
||||
if (mutations.length === 0) {
|
||||
console.log('No mutations need backfilling. All done!');
|
||||
return;
|
||||
}
|
||||
|
||||
let processedCount = 0;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const errors: Array<{ id: string; error: string }> = [];
|
||||
|
||||
// Process each mutation
|
||||
for (const mutation of mutations) {
|
||||
try {
|
||||
// Generate structural hashes
|
||||
const structureHashBefore = generateStructuralHash(mutation.workflow_before);
|
||||
const structureHashAfter = generateStructuralHash(mutation.workflow_after);
|
||||
|
||||
if (!structureHashBefore || !structureHashAfter) {
|
||||
console.warn(`Skipping mutation ${mutation.id}: Failed to generate hashes`);
|
||||
errors.push({ id: mutation.id, error: 'Failed to generate hashes' });
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update database
|
||||
const success = await updateMutation(mutation.id, structureHashBefore, structureHashAfter);
|
||||
|
||||
if (success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorCount++;
|
||||
errors.push({ id: mutation.id, error: 'Database update failed' });
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
|
||||
// Progress update every 100 mutations
|
||||
if (processedCount % 100 === 0) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
const rate = (processedCount / (Date.now() - startTime) * 1000).toFixed(1);
|
||||
console.log(
|
||||
`Progress: ${processedCount}/${mutations.length} (${((processedCount / mutations.length) * 100).toFixed(1)}%) | ` +
|
||||
`Success: ${successCount} | Errors: ${errorCount} | Rate: ${rate}/s | Elapsed: ${elapsed}s`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error processing mutation ${mutation.id}:`, error);
|
||||
errors.push({ id: mutation.id, error: String(error) });
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('BACKFILL COMPLETE');
|
||||
console.log('='.repeat(80));
|
||||
console.log(`Total mutations processed: ${processedCount}`);
|
||||
console.log(`Successfully updated: ${successCount}`);
|
||||
console.log(`Errors: ${errorCount}`);
|
||||
console.log(`Duration: ${duration}s`);
|
||||
console.log(`Average rate: ${(processedCount / (Date.now() - startTime) * 1000).toFixed(1)} mutations/s`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log('\nErrors encountered:');
|
||||
errors.slice(0, 10).forEach(({ id, error }) => {
|
||||
console.log(` - ${id}: ${error}`);
|
||||
});
|
||||
if (errors.length > 10) {
|
||||
console.log(` ... and ${errors.length - 10} more errors`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify cross-reference matches
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('VERIFYING CROSS-REFERENCE MATCHES');
|
||||
console.log('='.repeat(80));
|
||||
|
||||
const { data: statsData, error: statsError } = await supabase.rpc('get_mutation_crossref_stats');
|
||||
|
||||
if (statsError) {
|
||||
console.error('Failed to get cross-reference stats:', statsError.message);
|
||||
} else if (statsData && statsData.length > 0) {
|
||||
const stats = statsData[0];
|
||||
console.log(`Total mutations: ${stats.total_mutations}`);
|
||||
console.log(`Before matches: ${stats.before_matches} (${stats.before_match_rate}%)`);
|
||||
console.log(`After matches: ${stats.after_matches} (${stats.after_match_rate}%)`);
|
||||
console.log(`Both matches: ${stats.both_matches}`);
|
||||
}
|
||||
|
||||
console.log('\nBackfill process completed successfully! ✓');
|
||||
}
|
||||
|
||||
// Run the backfill
|
||||
backfillMutations().catch((error) => {
|
||||
console.error('Fatal error during backfill:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
45
scripts/generate-initial-release-notes.js
Normal file
45
scripts/generate-initial-release-notes.js
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate release notes for the initial release
|
||||
* Used by GitHub Actions when no previous tag exists
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
function generateInitialReleaseNotes(version) {
|
||||
try {
|
||||
// Get total commit count
|
||||
const commitCount = execSync('git rev-list --count HEAD', { encoding: 'utf8' }).trim();
|
||||
|
||||
// Generate release notes
|
||||
const releaseNotes = [
|
||||
'### 🎉 Initial Release',
|
||||
'',
|
||||
`This is the initial release of n8n-mcp v${version}.`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'**Release Statistics:**',
|
||||
`- Commit count: ${commitCount}`,
|
||||
'- First release setup'
|
||||
];
|
||||
|
||||
return releaseNotes.join('\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error generating initial release notes: ${error.message}`);
|
||||
return `Failed to generate initial release notes: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const version = process.argv[2];
|
||||
|
||||
if (!version) {
|
||||
console.error('Usage: generate-initial-release-notes.js <version>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const releaseNotes = generateInitialReleaseNotes(version);
|
||||
console.log(releaseNotes);
|
||||
121
scripts/generate-release-notes.js
Normal file
121
scripts/generate-release-notes.js
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate release notes from commit messages between two tags
|
||||
* Used by GitHub Actions to create automated release notes
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function generateReleaseNotes(previousTag, currentTag) {
|
||||
try {
|
||||
console.log(`Generating release notes from ${previousTag} to ${currentTag}`);
|
||||
|
||||
// Get commits between tags
|
||||
const gitLogCommand = `git log --pretty=format:"%H|%s|%an|%ae|%ad" --date=short --no-merges ${previousTag}..${currentTag}`;
|
||||
const commitsOutput = execSync(gitLogCommand, { encoding: 'utf8' });
|
||||
|
||||
if (!commitsOutput.trim()) {
|
||||
console.log('No commits found between tags');
|
||||
return 'No changes in this release.';
|
||||
}
|
||||
|
||||
const commits = commitsOutput.trim().split('\n').map(line => {
|
||||
const [hash, subject, author, email, date] = line.split('|');
|
||||
return { hash, subject, author, email, date };
|
||||
});
|
||||
|
||||
// Categorize commits
|
||||
const categories = {
|
||||
'feat': { title: '✨ Features', commits: [] },
|
||||
'fix': { title: '🐛 Bug Fixes', commits: [] },
|
||||
'docs': { title: '📚 Documentation', commits: [] },
|
||||
'refactor': { title: '♻️ Refactoring', commits: [] },
|
||||
'test': { title: '🧪 Testing', commits: [] },
|
||||
'perf': { title: '⚡ Performance', commits: [] },
|
||||
'style': { title: '💅 Styling', commits: [] },
|
||||
'ci': { title: '🔧 CI/CD', commits: [] },
|
||||
'build': { title: '📦 Build', commits: [] },
|
||||
'chore': { title: '🔧 Maintenance', commits: [] },
|
||||
'other': { title: '📝 Other Changes', commits: [] }
|
||||
};
|
||||
|
||||
commits.forEach(commit => {
|
||||
const subject = commit.subject.toLowerCase();
|
||||
let categorized = false;
|
||||
|
||||
// Check for conventional commit prefixes
|
||||
for (const [prefix, category] of Object.entries(categories)) {
|
||||
if (prefix !== 'other' && subject.startsWith(`${prefix}:`)) {
|
||||
category.commits.push(commit);
|
||||
categorized = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If not categorized, put in other
|
||||
if (!categorized) {
|
||||
categories.other.commits.push(commit);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate release notes
|
||||
const releaseNotes = [];
|
||||
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (category.commits.length > 0) {
|
||||
releaseNotes.push(`### ${category.title}`);
|
||||
releaseNotes.push('');
|
||||
|
||||
category.commits.forEach(commit => {
|
||||
// Clean up the subject by removing the prefix if it exists
|
||||
let cleanSubject = commit.subject;
|
||||
const colonIndex = cleanSubject.indexOf(':');
|
||||
if (colonIndex !== -1 && cleanSubject.substring(0, colonIndex).match(/^(feat|fix|docs|refactor|test|perf|style|ci|build|chore)$/)) {
|
||||
cleanSubject = cleanSubject.substring(colonIndex + 1).trim();
|
||||
// Capitalize first letter
|
||||
cleanSubject = cleanSubject.charAt(0).toUpperCase() + cleanSubject.slice(1);
|
||||
}
|
||||
|
||||
releaseNotes.push(`- ${cleanSubject} (${commit.hash.substring(0, 7)})`);
|
||||
});
|
||||
|
||||
releaseNotes.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Add commit statistics
|
||||
const totalCommits = commits.length;
|
||||
const contributors = [...new Set(commits.map(c => c.author))];
|
||||
|
||||
releaseNotes.push('---');
|
||||
releaseNotes.push('');
|
||||
releaseNotes.push(`**Release Statistics:**`);
|
||||
releaseNotes.push(`- ${totalCommits} commit${totalCommits !== 1 ? 's' : ''}`);
|
||||
releaseNotes.push(`- ${contributors.length} contributor${contributors.length !== 1 ? 's' : ''}`);
|
||||
|
||||
if (contributors.length <= 5) {
|
||||
releaseNotes.push(`- Contributors: ${contributors.join(', ')}`);
|
||||
}
|
||||
|
||||
return releaseNotes.join('\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error generating release notes: ${error.message}`);
|
||||
return `Failed to generate release notes: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const previousTag = process.argv[2];
|
||||
const currentTag = process.argv[3];
|
||||
|
||||
if (!previousTag || !currentTag) {
|
||||
console.error('Usage: generate-release-notes.js <previous-tag> <current-tag>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const releaseNotes = generateReleaseNotes(previousTag, currentTag);
|
||||
console.log(releaseNotes);
|
||||
99
scripts/process-batch-metadata.ts
Normal file
99
scripts/process-batch-metadata.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env ts-node
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
|
||||
interface BatchResponse {
|
||||
id: string;
|
||||
custom_id: string;
|
||||
response: {
|
||||
status_code: number;
|
||||
body: {
|
||||
choices: Array<{
|
||||
message: {
|
||||
content: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
error: any;
|
||||
}
|
||||
|
||||
async function processBatchMetadata(batchFile: string) {
|
||||
console.log(`📥 Processing batch file: ${batchFile}`);
|
||||
|
||||
// Read the JSONL file
|
||||
const content = fs.readFileSync(batchFile, 'utf-8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
console.log(`📊 Found ${lines.length} batch responses`);
|
||||
|
||||
// Initialize database
|
||||
const db = await createDatabaseAdapter('./data/nodes.db');
|
||||
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const response: BatchResponse = JSON.parse(line);
|
||||
|
||||
// Extract template ID from custom_id (format: "template-9100")
|
||||
const templateId = parseInt(response.custom_id.replace('template-', ''));
|
||||
|
||||
// Check for errors
|
||||
if (response.error || response.response.status_code !== 200) {
|
||||
console.warn(`⚠️ Template ${templateId}: API error`, response.error);
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract metadata from response
|
||||
const metadataJson = response.response.body.choices[0].message.content;
|
||||
|
||||
// Validate it's valid JSON
|
||||
JSON.parse(metadataJson); // Will throw if invalid
|
||||
|
||||
// Update database
|
||||
const stmt = db.prepare(`
|
||||
UPDATE templates
|
||||
SET metadata_json = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(metadataJson, templateId);
|
||||
updated++;
|
||||
|
||||
console.log(`✅ Template ${templateId}: Updated metadata`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Error processing line:`, error.message);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Close database
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
|
||||
console.log(`\n📈 Summary:`);
|
||||
console.log(` - Updated: ${updated}`);
|
||||
console.log(` - Skipped: ${skipped}`);
|
||||
console.log(` - Errors: ${errors}`);
|
||||
console.log(` - Total: ${lines.length}`);
|
||||
}
|
||||
|
||||
// Main
|
||||
const batchFile = process.argv[2] || '/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/docs/batch_68fff7242850819091cfed64f10fb6b4_output.jsonl';
|
||||
|
||||
processBatchMetadata(batchFile)
|
||||
.then(() => {
|
||||
console.log('\n✅ Batch processing complete!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Batch processing failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -11,29 +11,8 @@ NC='\033[0m' # No Color
|
||||
|
||||
echo "🚀 Preparing n8n-mcp for npm publish..."
|
||||
|
||||
# Run tests first to ensure quality
|
||||
echo "🧪 Running tests..."
|
||||
TEST_OUTPUT=$(npm test 2>&1)
|
||||
TEST_EXIT_CODE=$?
|
||||
|
||||
# Check test results - look for actual test failures vs coverage issues
|
||||
if echo "$TEST_OUTPUT" | grep -q "Tests.*failed"; then
|
||||
# Extract failed count using sed (portable)
|
||||
FAILED_COUNT=$(echo "$TEST_OUTPUT" | sed -n 's/.*Tests.*\([0-9]*\) failed.*/\1/p' | head -1)
|
||||
if [ "$FAILED_COUNT" != "0" ] && [ "$FAILED_COUNT" != "" ]; then
|
||||
echo -e "${RED}❌ $FAILED_COUNT test(s) failed. Aborting publish.${NC}"
|
||||
echo "$TEST_OUTPUT" | tail -20
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# If we got here, tests passed - check coverage
|
||||
if echo "$TEST_OUTPUT" | grep -q "Coverage.*does not meet global threshold"; then
|
||||
echo -e "${YELLOW}⚠️ All tests passed but coverage is below threshold${NC}"
|
||||
echo -e "${YELLOW} Consider improving test coverage before next release${NC}"
|
||||
else
|
||||
echo -e "${GREEN}✅ All tests passed with good coverage!${NC}"
|
||||
fi
|
||||
# Skip tests - they already run in CI before merge/publish
|
||||
echo "⏭️ Skipping tests (already verified in CI)"
|
||||
|
||||
# Sync version to runtime package first
|
||||
echo "🔄 Syncing version to package.runtime.json..."
|
||||
@@ -80,6 +59,15 @@ node -e "
|
||||
const pkg = require('./package.json');
|
||||
pkg.name = 'n8n-mcp';
|
||||
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (MCP)';
|
||||
pkg.main = 'dist/index.js';
|
||||
pkg.types = 'dist/index.d.ts';
|
||||
pkg.exports = {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
require: './dist/index.js',
|
||||
import: './dist/index.js'
|
||||
}
|
||||
};
|
||||
pkg.bin = { 'n8n-mcp': './dist/mcp/index.js' };
|
||||
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
|
||||
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
|
||||
|
||||
470
scripts/test-structure-validation.ts
Normal file
470
scripts/test-structure-validation.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env ts-node
|
||||
/**
|
||||
* Phase 3: Real-World Type Structure Validation
|
||||
*
|
||||
* Tests type structure validation against real workflow templates from n8n.io
|
||||
* to ensure production readiness. Validates filter, resourceMapper,
|
||||
* assignmentCollection, and resourceLocator types.
|
||||
*
|
||||
* Usage:
|
||||
* npm run build && node dist/scripts/test-structure-validation.js
|
||||
*
|
||||
* or with ts-node:
|
||||
* npx ts-node scripts/test-structure-validation.ts
|
||||
*/
|
||||
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
|
||||
import type { NodePropertyTypes } from 'n8n-workflow';
|
||||
import { gunzipSync } from 'zlib';
|
||||
|
||||
interface ValidationResult {
|
||||
templateId: number;
|
||||
templateName: string;
|
||||
templateViews: number;
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
propertyName: string;
|
||||
propertyType: NodePropertyTypes;
|
||||
valid: boolean;
|
||||
errors: Array<{ type: string; property?: string; message: string }>;
|
||||
warnings: Array<{ type: string; property?: string; message: string }>;
|
||||
validationTimeMs: number;
|
||||
}
|
||||
|
||||
interface ValidationStats {
|
||||
totalTemplates: number;
|
||||
totalNodes: number;
|
||||
totalValidations: number;
|
||||
passedValidations: number;
|
||||
failedValidations: number;
|
||||
byType: Record<string, { passed: number; failed: number }>;
|
||||
byError: Record<string, number>;
|
||||
avgValidationTimeMs: number;
|
||||
maxValidationTimeMs: number;
|
||||
}
|
||||
|
||||
// Special types we want to validate
|
||||
const SPECIAL_TYPES: NodePropertyTypes[] = [
|
||||
'filter',
|
||||
'resourceMapper',
|
||||
'assignmentCollection',
|
||||
'resourceLocator',
|
||||
];
|
||||
|
||||
function decompressWorkflow(compressed: string): any {
|
||||
try {
|
||||
const buffer = Buffer.from(compressed, 'base64');
|
||||
const decompressed = gunzipSync(buffer);
|
||||
return JSON.parse(decompressed.toString('utf-8'));
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to decompress workflow: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopTemplates(db: any, limit: number = 100) {
|
||||
console.log(`📥 Loading top ${limit} templates by popularity...\n`);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
workflow_json_compressed,
|
||||
views
|
||||
FROM templates
|
||||
WHERE workflow_json_compressed IS NOT NULL
|
||||
ORDER BY views DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
const templates = stmt.all(limit);
|
||||
console.log(`✓ Loaded ${templates.length} templates\n`);
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
function extractNodesWithSpecialTypes(workflowJson: any): Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
properties: Array<{ name: string; type: NodePropertyTypes; value: any }>;
|
||||
}> {
|
||||
const results: Array<any> = [];
|
||||
|
||||
if (!workflowJson || !workflowJson.nodes || !Array.isArray(workflowJson.nodes)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const node of workflowJson.nodes) {
|
||||
// Check if node has parameters with special types
|
||||
if (!node.parameters || typeof node.parameters !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const specialProperties: Array<{ name: string; type: NodePropertyTypes; value: any }> = [];
|
||||
|
||||
// Check each parameter against our special types
|
||||
for (const [paramName, paramValue] of Object.entries(node.parameters)) {
|
||||
// Try to infer type from structure
|
||||
const inferredType = inferPropertyType(paramValue);
|
||||
|
||||
if (inferredType && SPECIAL_TYPES.includes(inferredType)) {
|
||||
specialProperties.push({
|
||||
name: paramName,
|
||||
type: inferredType,
|
||||
value: paramValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (specialProperties.length > 0) {
|
||||
results.push({
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
nodeType: node.type,
|
||||
properties: specialProperties,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function inferPropertyType(value: any): NodePropertyTypes | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter type: has combinator and conditions
|
||||
if (value.combinator && value.conditions) {
|
||||
return 'filter';
|
||||
}
|
||||
|
||||
// ResourceMapper type: has mappingMode
|
||||
if (value.mappingMode) {
|
||||
return 'resourceMapper';
|
||||
}
|
||||
|
||||
// AssignmentCollection type: has assignments array
|
||||
if (value.assignments && Array.isArray(value.assignments)) {
|
||||
return 'assignmentCollection';
|
||||
}
|
||||
|
||||
// ResourceLocator type: has mode and value
|
||||
if (value.mode && value.hasOwnProperty('value')) {
|
||||
return 'resourceLocator';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function validateTemplate(
|
||||
templateId: number,
|
||||
templateName: string,
|
||||
templateViews: number,
|
||||
workflowJson: any
|
||||
): Promise<ValidationResult[]> {
|
||||
const results: ValidationResult[] = [];
|
||||
|
||||
// Extract nodes with special types
|
||||
const nodesWithSpecialTypes = extractNodesWithSpecialTypes(workflowJson);
|
||||
|
||||
for (const node of nodesWithSpecialTypes) {
|
||||
for (const prop of node.properties) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create property definition for validation
|
||||
const properties = [
|
||||
{
|
||||
name: prop.name,
|
||||
type: prop.type,
|
||||
required: true,
|
||||
displayName: prop.name,
|
||||
default: {},
|
||||
},
|
||||
];
|
||||
|
||||
// Create config with just this property
|
||||
const config = {
|
||||
[prop.name]: prop.value,
|
||||
};
|
||||
|
||||
try {
|
||||
// Run validation
|
||||
const validationResult = EnhancedConfigValidator.validateWithMode(
|
||||
node.nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
const validationTimeMs = Date.now() - startTime;
|
||||
|
||||
results.push({
|
||||
templateId,
|
||||
templateName,
|
||||
templateViews,
|
||||
nodeId: node.nodeId,
|
||||
nodeName: node.nodeName,
|
||||
nodeType: node.nodeType,
|
||||
propertyName: prop.name,
|
||||
propertyType: prop.type,
|
||||
valid: validationResult.valid,
|
||||
errors: validationResult.errors || [],
|
||||
warnings: validationResult.warnings || [],
|
||||
validationTimeMs,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const validationTimeMs = Date.now() - startTime;
|
||||
|
||||
results.push({
|
||||
templateId,
|
||||
templateName,
|
||||
templateViews,
|
||||
nodeId: node.nodeId,
|
||||
nodeName: node.nodeName,
|
||||
nodeType: node.nodeType,
|
||||
propertyName: prop.name,
|
||||
propertyType: prop.type,
|
||||
valid: false,
|
||||
errors: [
|
||||
{
|
||||
type: 'exception',
|
||||
property: prop.name,
|
||||
message: `Validation threw exception: ${error.message}`,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
validationTimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function calculateStats(results: ValidationResult[]): ValidationStats {
|
||||
const stats: ValidationStats = {
|
||||
totalTemplates: new Set(results.map(r => r.templateId)).size,
|
||||
totalNodes: new Set(results.map(r => `${r.templateId}-${r.nodeId}`)).size,
|
||||
totalValidations: results.length,
|
||||
passedValidations: results.filter(r => r.valid).length,
|
||||
failedValidations: results.filter(r => !r.valid).length,
|
||||
byType: {},
|
||||
byError: {},
|
||||
avgValidationTimeMs: 0,
|
||||
maxValidationTimeMs: 0,
|
||||
};
|
||||
|
||||
// Stats by type
|
||||
for (const type of SPECIAL_TYPES) {
|
||||
const typeResults = results.filter(r => r.propertyType === type);
|
||||
stats.byType[type] = {
|
||||
passed: typeResults.filter(r => r.valid).length,
|
||||
failed: typeResults.filter(r => !r.valid).length,
|
||||
};
|
||||
}
|
||||
|
||||
// Error frequency
|
||||
for (const result of results.filter(r => !r.valid)) {
|
||||
for (const error of result.errors) {
|
||||
const key = `${error.type}: ${error.message}`;
|
||||
stats.byError[key] = (stats.byError[key] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Performance stats
|
||||
if (results.length > 0) {
|
||||
stats.avgValidationTimeMs =
|
||||
results.reduce((sum, r) => sum + r.validationTimeMs, 0) / results.length;
|
||||
stats.maxValidationTimeMs = Math.max(...results.map(r => r.validationTimeMs));
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
function printStats(stats: ValidationStats) {
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('VALIDATION STATISTICS');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
console.log(`📊 Total Templates Tested: ${stats.totalTemplates}`);
|
||||
console.log(`📊 Total Nodes with Special Types: ${stats.totalNodes}`);
|
||||
console.log(`📊 Total Property Validations: ${stats.totalValidations}\n`);
|
||||
|
||||
const passRate = (stats.passedValidations / stats.totalValidations * 100).toFixed(2);
|
||||
const failRate = (stats.failedValidations / stats.totalValidations * 100).toFixed(2);
|
||||
|
||||
console.log(`✅ Passed: ${stats.passedValidations} (${passRate}%)`);
|
||||
console.log(`❌ Failed: ${stats.failedValidations} (${failRate}%)\n`);
|
||||
|
||||
console.log('By Property Type:');
|
||||
console.log('-'.repeat(80));
|
||||
for (const [type, counts] of Object.entries(stats.byType)) {
|
||||
const total = counts.passed + counts.failed;
|
||||
if (total === 0) {
|
||||
console.log(` ${type}: No occurrences found`);
|
||||
} else {
|
||||
const typePassRate = (counts.passed / total * 100).toFixed(2);
|
||||
console.log(` ${type}: ${counts.passed}/${total} passed (${typePassRate}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n⚡ Performance:');
|
||||
console.log('-'.repeat(80));
|
||||
console.log(` Average validation time: ${stats.avgValidationTimeMs.toFixed(2)}ms`);
|
||||
console.log(` Maximum validation time: ${stats.maxValidationTimeMs.toFixed(2)}ms`);
|
||||
|
||||
const meetsTarget = stats.avgValidationTimeMs < 50;
|
||||
console.log(` Target (<50ms): ${meetsTarget ? '✅ MET' : '❌ NOT MET'}\n`);
|
||||
|
||||
if (Object.keys(stats.byError).length > 0) {
|
||||
console.log('🔍 Most Common Errors:');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const sortedErrors = Object.entries(stats.byError)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
|
||||
for (const [error, count] of sortedErrors) {
|
||||
console.log(` ${count}x: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printFailures(results: ValidationResult[], maxFailures: number = 20) {
|
||||
const failures = results.filter(r => !r.valid);
|
||||
|
||||
if (failures.length === 0) {
|
||||
console.log('\n✨ No failures! All validations passed.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log(`VALIDATION FAILURES (showing first ${Math.min(maxFailures, failures.length)})` );
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
for (let i = 0; i < Math.min(maxFailures, failures.length); i++) {
|
||||
const failure = failures[i];
|
||||
|
||||
console.log(`Failure ${i + 1}/${failures.length}:`);
|
||||
console.log(` Template: ${failure.templateName} (ID: ${failure.templateId}, Views: ${failure.templateViews})`);
|
||||
console.log(` Node: ${failure.nodeName} (${failure.nodeType})`);
|
||||
console.log(` Property: ${failure.propertyName} (type: ${failure.propertyType})`);
|
||||
console.log(` Errors:`);
|
||||
|
||||
for (const error of failure.errors) {
|
||||
console.log(` - [${error.type}] ${error.property}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (failure.warnings.length > 0) {
|
||||
console.log(` Warnings:`);
|
||||
for (const warning of failure.warnings) {
|
||||
console.log(` - [${warning.type}] ${warning.property}: ${warning.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (failures.length > maxFailures) {
|
||||
console.log(`... and ${failures.length - maxFailures} more failures\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(80));
|
||||
console.log('PHASE 3: REAL-WORLD TYPE STRUCTURE VALIDATION');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
// Initialize database
|
||||
console.log('🔌 Connecting to database...');
|
||||
const db = await createDatabaseAdapter('./data/nodes.db');
|
||||
console.log('✓ Database connected\n');
|
||||
|
||||
// Load templates
|
||||
const templates = await loadTopTemplates(db, 100);
|
||||
|
||||
// Validate each template
|
||||
console.log('🔍 Validating templates...\n');
|
||||
|
||||
const allResults: ValidationResult[] = [];
|
||||
let processedCount = 0;
|
||||
let nodesFound = 0;
|
||||
|
||||
for (const template of templates) {
|
||||
processedCount++;
|
||||
|
||||
let workflowJson;
|
||||
try {
|
||||
workflowJson = decompressWorkflow(template.workflow_json_compressed);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Template ${template.id}: Decompression failed, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const results = await validateTemplate(
|
||||
template.id,
|
||||
template.name,
|
||||
template.views,
|
||||
workflowJson
|
||||
);
|
||||
|
||||
if (results.length > 0) {
|
||||
nodesFound += new Set(results.map(r => r.nodeId)).size;
|
||||
allResults.push(...results);
|
||||
|
||||
const passedCount = results.filter(r => r.valid).length;
|
||||
const status = passedCount === results.length ? '✓' : '✗';
|
||||
console.log(
|
||||
`${status} Template ${processedCount}/${templates.length}: ` +
|
||||
`"${template.name}" (${results.length} validations, ${passedCount} passed)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✓ Processed ${processedCount} templates`);
|
||||
console.log(`✓ Found ${nodesFound} nodes with special types\n`);
|
||||
|
||||
// Calculate and print statistics
|
||||
const stats = calculateStats(allResults);
|
||||
printStats(stats);
|
||||
|
||||
// Print detailed failures
|
||||
printFailures(allResults);
|
||||
|
||||
// Success criteria check
|
||||
console.log('='.repeat(80));
|
||||
console.log('SUCCESS CRITERIA CHECK');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
const passRate = (stats.passedValidations / stats.totalValidations * 100);
|
||||
const falsePositiveRate = (stats.failedValidations / stats.totalValidations * 100);
|
||||
const avgTime = stats.avgValidationTimeMs;
|
||||
|
||||
console.log(`Pass Rate: ${passRate.toFixed(2)}% (target: >95%) ${passRate > 95 ? '✅' : '❌'}`);
|
||||
console.log(`False Positive Rate: ${falsePositiveRate.toFixed(2)}% (target: <5%) ${falsePositiveRate < 5 ? '✅' : '❌'}`);
|
||||
console.log(`Avg Validation Time: ${avgTime.toFixed(2)}ms (target: <50ms) ${avgTime < 50 ? '✅' : '❌'}\n`);
|
||||
|
||||
const allCriteriaMet = passRate > 95 && falsePositiveRate < 5 && avgTime < 50;
|
||||
|
||||
if (allCriteriaMet) {
|
||||
console.log('🎉 ALL SUCCESS CRITERIA MET! Phase 3 validation complete.\n');
|
||||
} else {
|
||||
console.log('⚠️ Some success criteria not met. Iteration required.\n');
|
||||
}
|
||||
|
||||
// Close database
|
||||
db.close();
|
||||
|
||||
process.exit(allCriteriaMet ? 0 : 1);
|
||||
}
|
||||
|
||||
// Run the script
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
287
scripts/test-workflow-versioning.ts
Normal file
287
scripts/test-workflow-versioning.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test Workflow Versioning System
|
||||
*
|
||||
* Tests the complete workflow rollback and versioning functionality:
|
||||
* - Automatic backup creation
|
||||
* - Auto-pruning to 10 versions
|
||||
* - Version history retrieval
|
||||
* - Rollback with validation
|
||||
* - Manual pruning and cleanup
|
||||
* - Storage statistics
|
||||
*/
|
||||
|
||||
import { NodeRepository } from '../src/database/node-repository';
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { WorkflowVersioningService } from '../src/services/workflow-versioning-service';
|
||||
import { logger } from '../src/utils/logger';
|
||||
import { existsSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Mock workflow for testing
|
||||
const createMockWorkflow = (id: string, name: string, nodeCount: number = 3) => ({
|
||||
id,
|
||||
name,
|
||||
active: false,
|
||||
nodes: Array.from({ length: nodeCount }, (_, i) => ({
|
||||
id: `node-${i}`,
|
||||
name: `Node ${i}`,
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [250 + i * 200, 300],
|
||||
parameters: { values: { string: [{ name: `field${i}`, value: `value${i}` }] } }
|
||||
})),
|
||||
connections: nodeCount > 1 ? {
|
||||
'node-0': { main: [[{ node: 'node-1', type: 'main', index: 0 }]] },
|
||||
...(nodeCount > 2 && { 'node-1': { main: [[{ node: 'node-2', type: 'main', index: 0 }]] } })
|
||||
} : {},
|
||||
settings: {}
|
||||
});
|
||||
|
||||
async function runTests() {
|
||||
console.log('🧪 Testing Workflow Versioning System\n');
|
||||
|
||||
// Find database path
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), 'data', 'nodes.db'),
|
||||
path.join(__dirname, '../../data', 'nodes.db'),
|
||||
'./data/nodes.db'
|
||||
];
|
||||
|
||||
let dbPath: string | null = null;
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p)) {
|
||||
dbPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbPath) {
|
||||
console.error('❌ Database not found. Please run npm run rebuild first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`📁 Using database: ${dbPath}\n`);
|
||||
|
||||
// Initialize repository
|
||||
const db = await createDatabaseAdapter(dbPath);
|
||||
const repository = new NodeRepository(db);
|
||||
const service = new WorkflowVersioningService(repository);
|
||||
|
||||
const workflowId = 'test-workflow-001';
|
||||
let testsPassed = 0;
|
||||
let testsFailed = 0;
|
||||
|
||||
try {
|
||||
// Test 1: Create initial backup
|
||||
console.log('📝 Test 1: Create initial backup');
|
||||
const workflow1 = createMockWorkflow(workflowId, 'Test Workflow v1', 3);
|
||||
const backup1 = await service.createBackup(workflowId, workflow1, {
|
||||
trigger: 'partial_update',
|
||||
operations: [{ type: 'addNode', node: workflow1.nodes[0] }]
|
||||
});
|
||||
|
||||
if (backup1.versionId && backup1.versionNumber === 1 && backup1.pruned === 0) {
|
||||
console.log('✅ Initial backup created successfully');
|
||||
console.log(` Version ID: ${backup1.versionId}, Version Number: ${backup1.versionNumber}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to create initial backup');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 2: Create multiple backups to test auto-pruning
|
||||
console.log('\n📝 Test 2: Create 12 backups to test auto-pruning (should keep only 10)');
|
||||
for (let i = 2; i <= 12; i++) {
|
||||
const workflow = createMockWorkflow(workflowId, `Test Workflow v${i}`, 3 + i);
|
||||
await service.createBackup(workflowId, workflow, {
|
||||
trigger: i % 3 === 0 ? 'full_update' : 'partial_update',
|
||||
operations: [{ type: 'addNode', node: { id: `node-${i}` } }]
|
||||
});
|
||||
}
|
||||
|
||||
const versions = await service.getVersionHistory(workflowId, 100);
|
||||
if (versions.length === 10) {
|
||||
console.log(`✅ Auto-pruning works correctly (kept exactly 10 versions)`);
|
||||
console.log(` Latest version: ${versions[0].versionNumber}, Oldest: ${versions[9].versionNumber}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log(`❌ Auto-pruning failed (expected 10 versions, got ${versions.length})`);
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 3: Get version history
|
||||
console.log('\n📝 Test 3: Get version history');
|
||||
const history = await service.getVersionHistory(workflowId, 5);
|
||||
if (history.length === 5 && history[0].versionNumber > history[4].versionNumber) {
|
||||
console.log(`✅ Version history retrieved successfully (${history.length} versions)`);
|
||||
console.log(' Recent versions:');
|
||||
history.forEach(v => {
|
||||
console.log(` - v${v.versionNumber} (${v.trigger}) - ${v.workflowName} - ${(v.size / 1024).toFixed(2)} KB`);
|
||||
});
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to get version history');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 4: Get specific version
|
||||
console.log('\n📝 Test 4: Get specific version details');
|
||||
const specificVersion = await service.getVersion(history[2].id);
|
||||
if (specificVersion && specificVersion.workflowSnapshot) {
|
||||
console.log(`✅ Retrieved version ${specificVersion.versionNumber} successfully`);
|
||||
console.log(` Workflow name: ${specificVersion.workflowName}`);
|
||||
console.log(` Node count: ${specificVersion.workflowSnapshot.nodes.length}`);
|
||||
console.log(` Trigger: ${specificVersion.trigger}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to get specific version');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 5: Compare two versions
|
||||
console.log('\n📝 Test 5: Compare two versions');
|
||||
if (history.length >= 2) {
|
||||
const diff = await service.compareVersions(history[0].id, history[1].id);
|
||||
console.log(`✅ Version comparison successful`);
|
||||
console.log(` Comparing v${diff.version1Number} → v${diff.version2Number}`);
|
||||
console.log(` Added nodes: ${diff.addedNodes.length}`);
|
||||
console.log(` Removed nodes: ${diff.removedNodes.length}`);
|
||||
console.log(` Modified nodes: ${diff.modifiedNodes.length}`);
|
||||
console.log(` Connection changes: ${diff.connectionChanges}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Not enough versions to compare');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 6: Manual pruning
|
||||
console.log('\n📝 Test 6: Manual pruning (keep only 5 versions)');
|
||||
const pruneResult = await service.pruneVersions(workflowId, 5);
|
||||
if (pruneResult.pruned === 5 && pruneResult.remaining === 5) {
|
||||
console.log(`✅ Manual pruning successful`);
|
||||
console.log(` Pruned: ${pruneResult.pruned} versions, Remaining: ${pruneResult.remaining}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log(`❌ Manual pruning failed (expected 5 pruned, 5 remaining, got ${pruneResult.pruned} pruned, ${pruneResult.remaining} remaining)`);
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 7: Storage statistics
|
||||
console.log('\n📝 Test 7: Storage statistics');
|
||||
const stats = await service.getStorageStats();
|
||||
if (stats.totalVersions > 0 && stats.byWorkflow.length > 0) {
|
||||
console.log(`✅ Storage stats retrieved successfully`);
|
||||
console.log(` Total versions: ${stats.totalVersions}`);
|
||||
console.log(` Total size: ${stats.totalSizeFormatted}`);
|
||||
console.log(` Workflows with versions: ${stats.byWorkflow.length}`);
|
||||
stats.byWorkflow.forEach(w => {
|
||||
console.log(` - ${w.workflowName}: ${w.versionCount} versions, ${w.totalSizeFormatted}`);
|
||||
});
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to get storage stats');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 8: Delete specific version
|
||||
console.log('\n📝 Test 8: Delete specific version');
|
||||
const versionsBeforeDelete = await service.getVersionHistory(workflowId, 100);
|
||||
const versionToDelete = versionsBeforeDelete[versionsBeforeDelete.length - 1];
|
||||
const deleteResult = await service.deleteVersion(versionToDelete.id);
|
||||
const versionsAfterDelete = await service.getVersionHistory(workflowId, 100);
|
||||
|
||||
if (deleteResult.success && versionsAfterDelete.length === versionsBeforeDelete.length - 1) {
|
||||
console.log(`✅ Version deletion successful`);
|
||||
console.log(` Deleted version ${versionToDelete.versionNumber}`);
|
||||
console.log(` Remaining versions: ${versionsAfterDelete.length}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to delete version');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 9: Test different trigger types
|
||||
console.log('\n📝 Test 9: Test different trigger types');
|
||||
const workflow2 = createMockWorkflow(workflowId, 'Test Workflow Autofix', 2);
|
||||
const backupAutofix = await service.createBackup(workflowId, workflow2, {
|
||||
trigger: 'autofix',
|
||||
fixTypes: ['expression-format', 'typeversion-correction']
|
||||
});
|
||||
|
||||
const workflow3 = createMockWorkflow(workflowId, 'Test Workflow Full Update', 4);
|
||||
const backupFull = await service.createBackup(workflowId, workflow3, {
|
||||
trigger: 'full_update',
|
||||
metadata: { reason: 'Major refactoring' }
|
||||
});
|
||||
|
||||
const allVersions = await service.getVersionHistory(workflowId, 100);
|
||||
const autofixVersions = allVersions.filter(v => v.trigger === 'autofix');
|
||||
const fullUpdateVersions = allVersions.filter(v => v.trigger === 'full_update');
|
||||
const partialUpdateVersions = allVersions.filter(v => v.trigger === 'partial_update');
|
||||
|
||||
if (autofixVersions.length > 0 && fullUpdateVersions.length > 0 && partialUpdateVersions.length > 0) {
|
||||
console.log(`✅ All trigger types working correctly`);
|
||||
console.log(` Partial updates: ${partialUpdateVersions.length}`);
|
||||
console.log(` Full updates: ${fullUpdateVersions.length}`);
|
||||
console.log(` Autofixes: ${autofixVersions.length}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to create versions with different trigger types');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 10: Cleanup - Delete all versions for workflow
|
||||
console.log('\n📝 Test 10: Delete all versions for workflow');
|
||||
const deleteAllResult = await service.deleteAllVersions(workflowId);
|
||||
const versionsAfterDeleteAll = await service.getVersionHistory(workflowId, 100);
|
||||
|
||||
if (deleteAllResult.deleted > 0 && versionsAfterDeleteAll.length === 0) {
|
||||
console.log(`✅ Delete all versions successful`);
|
||||
console.log(` Deleted ${deleteAllResult.deleted} versions`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to delete all versions');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 11: Truncate all versions (requires confirmation)
|
||||
console.log('\n📝 Test 11: Test truncate without confirmation');
|
||||
const truncateResult1 = await service.truncateAllVersions(false);
|
||||
if (truncateResult1.deleted === 0 && truncateResult1.message.includes('not confirmed')) {
|
||||
console.log(`✅ Truncate safety check works (requires confirmation)`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Truncate safety check failed');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 Test Summary');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`✅ Passed: ${testsPassed}`);
|
||||
console.log(`❌ Failed: ${testsFailed}`);
|
||||
console.log(`📈 Success Rate: ${((testsPassed / (testsPassed + testsFailed)) * 100).toFixed(1)}%`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
if (testsFailed === 0) {
|
||||
console.log('\n🎉 All tests passed! Workflow versioning system is working correctly.');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('\n⚠️ Some tests failed. Please review the implementation.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ Test suite failed with error:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
runTests().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
741
src/constants/type-structures.ts
Normal file
741
src/constants/type-structures.ts
Normal file
@@ -0,0 +1,741 @@
|
||||
/**
|
||||
* Type Structure Constants
|
||||
*
|
||||
* Complete definitions for all n8n NodePropertyTypes.
|
||||
* These structures define the expected data format, JavaScript type,
|
||||
* validation rules, and examples for each property type.
|
||||
*
|
||||
* Based on n8n-workflow v1.120.3 NodePropertyTypes
|
||||
*
|
||||
* @module constants/type-structures
|
||||
* @since 2.23.0
|
||||
*/
|
||||
|
||||
import type { NodePropertyTypes } from 'n8n-workflow';
|
||||
import type { TypeStructure } from '../types/type-structures';
|
||||
|
||||
/**
|
||||
* Complete type structure definitions for all 22 NodePropertyTypes
|
||||
*
|
||||
* Each entry defines:
|
||||
* - type: Category (primitive/object/collection/special)
|
||||
* - jsType: Underlying JavaScript type
|
||||
* - description: What this type represents
|
||||
* - structure: Expected data shape (for complex types)
|
||||
* - example: Working example value
|
||||
* - validation: Type-specific validation rules
|
||||
*
|
||||
* @constant
|
||||
*/
|
||||
export const TYPE_STRUCTURES: Record<NodePropertyTypes, TypeStructure> = {
|
||||
// ============================================================================
|
||||
// PRIMITIVE TYPES - Simple JavaScript values
|
||||
// ============================================================================
|
||||
|
||||
string: {
|
||||
type: 'primitive',
|
||||
jsType: 'string',
|
||||
description: 'A text value that can contain any characters',
|
||||
example: 'Hello World',
|
||||
examples: ['', 'A simple text', '{{ $json.name }}', 'https://example.com'],
|
||||
validation: {
|
||||
allowEmpty: true,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: ['Most common property type', 'Supports n8n expressions'],
|
||||
},
|
||||
|
||||
number: {
|
||||
type: 'primitive',
|
||||
jsType: 'number',
|
||||
description: 'A numeric value (integer or decimal)',
|
||||
example: 42,
|
||||
examples: [0, -10, 3.14, 100],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: ['Can be constrained with min/max in typeOptions'],
|
||||
},
|
||||
|
||||
boolean: {
|
||||
type: 'primitive',
|
||||
jsType: 'boolean',
|
||||
description: 'A true/false toggle value',
|
||||
example: true,
|
||||
examples: [true, false],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: false,
|
||||
},
|
||||
notes: ['Rendered as checkbox in n8n UI'],
|
||||
},
|
||||
|
||||
dateTime: {
|
||||
type: 'primitive',
|
||||
jsType: 'string',
|
||||
description: 'A date and time value in ISO 8601 format',
|
||||
example: '2024-01-20T10:30:00Z',
|
||||
examples: [
|
||||
'2024-01-20T10:30:00Z',
|
||||
'2024-01-20',
|
||||
'{{ $now }}',
|
||||
],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: true,
|
||||
pattern: '^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(\\.\\d{3})?Z?)?$',
|
||||
},
|
||||
notes: ['Accepts ISO 8601 format', 'Can use n8n date expressions'],
|
||||
},
|
||||
|
||||
color: {
|
||||
type: 'primitive',
|
||||
jsType: 'string',
|
||||
description: 'A color value in hex format',
|
||||
example: '#FF5733',
|
||||
examples: ['#FF5733', '#000000', '#FFFFFF', '{{ $json.color }}'],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: true,
|
||||
pattern: '^#[0-9A-Fa-f]{6}$',
|
||||
},
|
||||
notes: ['Must be 6-digit hex color', 'Rendered with color picker in UI'],
|
||||
},
|
||||
|
||||
json: {
|
||||
type: 'primitive',
|
||||
jsType: 'string',
|
||||
description: 'A JSON string that can be parsed into any structure',
|
||||
example: '{"key": "value", "nested": {"data": 123}}',
|
||||
examples: [
|
||||
'{}',
|
||||
'{"name": "John", "age": 30}',
|
||||
'[1, 2, 3]',
|
||||
'{{ $json }}',
|
||||
],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: ['Must be valid JSON when parsed', 'Often used for custom payloads'],
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// OPTION TYPES - Selection from predefined choices
|
||||
// ============================================================================
|
||||
|
||||
options: {
|
||||
type: 'primitive',
|
||||
jsType: 'string',
|
||||
description: 'Single selection from a list of predefined options',
|
||||
example: 'option1',
|
||||
examples: ['GET', 'POST', 'channelMessage', 'update'],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: false,
|
||||
},
|
||||
notes: [
|
||||
'Value must match one of the defined option values',
|
||||
'Rendered as dropdown in UI',
|
||||
'Options defined in property.options array',
|
||||
],
|
||||
},
|
||||
|
||||
multiOptions: {
|
||||
type: 'array',
|
||||
jsType: 'array',
|
||||
description: 'Multiple selections from a list of predefined options',
|
||||
structure: {
|
||||
items: {
|
||||
type: 'string',
|
||||
description: 'Selected option value',
|
||||
},
|
||||
},
|
||||
example: ['option1', 'option2'],
|
||||
examples: [[], ['GET', 'POST'], ['read', 'write', 'delete']],
|
||||
validation: {
|
||||
allowEmpty: true,
|
||||
allowExpressions: false,
|
||||
},
|
||||
notes: [
|
||||
'Array of option values',
|
||||
'Each value must exist in property.options',
|
||||
'Rendered as multi-select dropdown',
|
||||
],
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// COLLECTION TYPES - Complex nested structures
|
||||
// ============================================================================
|
||||
|
||||
collection: {
|
||||
type: 'collection',
|
||||
jsType: 'object',
|
||||
description: 'A group of related properties with dynamic values',
|
||||
structure: {
|
||||
properties: {
|
||||
'<propertyName>': {
|
||||
type: 'any',
|
||||
description: 'Any nested property from the collection definition',
|
||||
},
|
||||
},
|
||||
flexible: true,
|
||||
},
|
||||
example: {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
age: 30,
|
||||
},
|
||||
examples: [
|
||||
{},
|
||||
{ key1: 'value1', key2: 123 },
|
||||
{ nested: { deep: { value: true } } },
|
||||
],
|
||||
validation: {
|
||||
allowEmpty: true,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: [
|
||||
'Properties defined in property.values array',
|
||||
'Each property can be any type',
|
||||
'UI renders as expandable section',
|
||||
],
|
||||
},
|
||||
|
||||
fixedCollection: {
|
||||
type: 'collection',
|
||||
jsType: 'object',
|
||||
description: 'A collection with predefined groups of properties',
|
||||
structure: {
|
||||
properties: {
|
||||
'<collectionName>': {
|
||||
type: 'array',
|
||||
description: 'Array of collection items',
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'Collection item with defined properties',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
example: {
|
||||
headers: [
|
||||
{ name: 'Content-Type', value: 'application/json' },
|
||||
{ name: 'Authorization', value: 'Bearer token' },
|
||||
],
|
||||
},
|
||||
examples: [
|
||||
{},
|
||||
{ queryParameters: [{ name: 'id', value: '123' }] },
|
||||
{
|
||||
headers: [{ name: 'Accept', value: '*/*' }],
|
||||
queryParameters: [{ name: 'limit', value: '10' }],
|
||||
},
|
||||
],
|
||||
validation: {
|
||||
allowEmpty: true,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: [
|
||||
'Each collection has predefined structure',
|
||||
'Often used for headers, parameters, etc.',
|
||||
'Supports multiple values per collection',
|
||||
],
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// SPECIAL n8n TYPES - Advanced functionality
|
||||
// ============================================================================
|
||||
|
||||
resourceLocator: {
|
||||
type: 'special',
|
||||
jsType: 'object',
|
||||
description: 'A flexible way to specify a resource by ID, name, URL, or list',
|
||||
structure: {
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
description: 'How the resource is specified',
|
||||
enum: ['id', 'url', 'list'],
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
description: 'The resource identifier',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
required: ['mode', 'value'],
|
||||
},
|
||||
example: {
|
||||
mode: 'id',
|
||||
value: 'abc123',
|
||||
},
|
||||
examples: [
|
||||
{ mode: 'url', value: 'https://example.com/resource/123' },
|
||||
{ mode: 'list', value: 'item-from-dropdown' },
|
||||
{ mode: 'id', value: '{{ $json.resourceId }}' },
|
||||
],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: [
|
||||
'Provides flexible resource selection',
|
||||
'Mode determines how value is interpreted',
|
||||
'UI adapts based on selected mode',
|
||||
],
|
||||
},
|
||||
|
||||
resourceMapper: {
|
||||
type: 'special',
|
||||
jsType: 'object',
|
||||
description: 'Maps input data fields to resource fields with transformation options',
|
||||
structure: {
|
||||
properties: {
|
||||
mappingMode: {
|
||||
type: 'string',
|
||||
description: 'How fields are mapped',
|
||||
enum: ['defineBelow', 'autoMapInputData'],
|
||||
},
|
||||
value: {
|
||||
type: 'object',
|
||||
description: 'Field mappings',
|
||||
properties: {
|
||||
'<fieldName>': {
|
||||
type: 'string',
|
||||
description: 'Expression or value for this field',
|
||||
},
|
||||
},
|
||||
flexible: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
example: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {
|
||||
name: '{{ $json.fullName }}',
|
||||
email: '{{ $json.emailAddress }}',
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
examples: [
|
||||
{ mappingMode: 'autoMapInputData', value: {} },
|
||||
{
|
||||
mappingMode: 'defineBelow',
|
||||
value: { id: '{{ $json.userId }}', name: '{{ $json.name }}' },
|
||||
},
|
||||
],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: [
|
||||
'Complex mapping with UI assistance',
|
||||
'Can auto-map or manually define',
|
||||
'Supports field transformations',
|
||||
],
|
||||
},
|
||||
|
||||
filter: {
|
||||
type: 'special',
|
||||
jsType: 'object',
|
||||
description: 'Defines conditions for filtering data with boolean logic',
|
||||
structure: {
|
||||
properties: {
|
||||
conditions: {
|
||||
type: 'array',
|
||||
description: 'Array of filter conditions',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Unique condition identifier',
|
||||
required: true,
|
||||
},
|
||||
leftValue: {
|
||||
type: 'any',
|
||||
description: 'Left side of comparison',
|
||||
},
|
||||
operator: {
|
||||
type: 'object',
|
||||
description: 'Comparison operator',
|
||||
required: true,
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['string', 'number', 'boolean', 'dateTime', 'array', 'object'],
|
||||
required: true,
|
||||
},
|
||||
operation: {
|
||||
type: 'string',
|
||||
description: 'Operation to perform',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rightValue: {
|
||||
type: 'any',
|
||||
description: 'Right side of comparison',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
combinator: {
|
||||
type: 'string',
|
||||
description: 'How to combine conditions',
|
||||
enum: ['and', 'or'],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
required: ['conditions', 'combinator'],
|
||||
},
|
||||
example: {
|
||||
conditions: [
|
||||
{
|
||||
id: 'abc-123',
|
||||
leftValue: '{{ $json.status }}',
|
||||
operator: { type: 'string', operation: 'equals' },
|
||||
rightValue: 'active',
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: [
|
||||
'Advanced filtering UI in n8n',
|
||||
'Supports complex boolean logic',
|
||||
'Operations vary by data type',
|
||||
],
|
||||
},
|
||||
|
||||
assignmentCollection: {
|
||||
type: 'special',
|
||||
jsType: 'object',
|
||||
description: 'Defines variable assignments with expressions',
|
||||
structure: {
|
||||
properties: {
|
||||
assignments: {
|
||||
type: 'array',
|
||||
description: 'Array of variable assignments',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Unique assignment identifier',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Variable name',
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: 'any',
|
||||
description: 'Value to assign',
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Data type of the value',
|
||||
enum: ['string', 'number', 'boolean', 'array', 'object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
required: ['assignments'],
|
||||
},
|
||||
example: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'abc-123',
|
||||
name: 'userName',
|
||||
value: '{{ $json.name }}',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'def-456',
|
||||
name: 'userAge',
|
||||
value: 30,
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: [
|
||||
'Used in Set node and similar',
|
||||
'Each assignment can use expressions',
|
||||
'Type helps with validation',
|
||||
],
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// CREDENTIAL TYPES - Authentication and credentials
|
||||
// ============================================================================
|
||||
|
||||
credentials: {
|
||||
type: 'special',
|
||||
jsType: 'string',
|
||||
description: 'Reference to credential configuration',
|
||||
example: 'googleSheetsOAuth2Api',
|
||||
examples: ['httpBasicAuth', 'slackOAuth2Api', 'postgresApi'],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: false,
|
||||
},
|
||||
notes: [
|
||||
'References credential type name',
|
||||
'Credential must be configured in n8n',
|
||||
'Type name matches credential definition',
|
||||
],
|
||||
},
|
||||
|
||||
credentialsSelect: {
|
||||
type: 'special',
|
||||
jsType: 'string',
|
||||
description: 'Dropdown to select from available credentials',
|
||||
example: 'credential-id-123',
|
||||
examples: ['cred-abc', 'cred-def', '{{ $credentials.id }}'],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: [
|
||||
'User selects from configured credentials',
|
||||
'Returns credential ID',
|
||||
'Used when multiple credential instances exist',
|
||||
],
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// UI-ONLY TYPES - Display elements without data
|
||||
// ============================================================================
|
||||
|
||||
hidden: {
|
||||
type: 'special',
|
||||
jsType: 'string',
|
||||
description: 'Hidden property not shown in UI (used for internal logic)',
|
||||
example: '',
|
||||
validation: {
|
||||
allowEmpty: true,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: [
|
||||
'Not rendered in UI',
|
||||
'Can store metadata or computed values',
|
||||
'Often used for version tracking',
|
||||
],
|
||||
},
|
||||
|
||||
button: {
|
||||
type: 'special',
|
||||
jsType: 'string',
|
||||
description: 'Clickable button that triggers an action',
|
||||
example: '',
|
||||
validation: {
|
||||
allowEmpty: true,
|
||||
allowExpressions: false,
|
||||
},
|
||||
notes: [
|
||||
'Triggers action when clicked',
|
||||
'Does not store a value',
|
||||
'Action defined in routing property',
|
||||
],
|
||||
},
|
||||
|
||||
callout: {
|
||||
type: 'special',
|
||||
jsType: 'string',
|
||||
description: 'Informational message box (warning, info, success, error)',
|
||||
example: '',
|
||||
validation: {
|
||||
allowEmpty: true,
|
||||
allowExpressions: false,
|
||||
},
|
||||
notes: [
|
||||
'Display-only, no value stored',
|
||||
'Used for warnings and hints',
|
||||
'Style controlled by typeOptions',
|
||||
],
|
||||
},
|
||||
|
||||
notice: {
|
||||
type: 'special',
|
||||
jsType: 'string',
|
||||
description: 'Notice message displayed to user',
|
||||
example: '',
|
||||
validation: {
|
||||
allowEmpty: true,
|
||||
allowExpressions: false,
|
||||
},
|
||||
notes: ['Similar to callout', 'Display-only element', 'Provides contextual information'],
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY TYPES - Special-purpose functionality
|
||||
// ============================================================================
|
||||
|
||||
workflowSelector: {
|
||||
type: 'special',
|
||||
jsType: 'string',
|
||||
description: 'Dropdown to select another workflow',
|
||||
example: 'workflow-123',
|
||||
examples: ['wf-abc', '{{ $json.workflowId }}'],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: true,
|
||||
},
|
||||
notes: [
|
||||
'Selects from available workflows',
|
||||
'Returns workflow ID',
|
||||
'Used in Execute Workflow node',
|
||||
],
|
||||
},
|
||||
|
||||
curlImport: {
|
||||
type: 'special',
|
||||
jsType: 'string',
|
||||
description: 'Import configuration from cURL command',
|
||||
example: 'curl -X GET https://api.example.com/data',
|
||||
validation: {
|
||||
allowEmpty: true,
|
||||
allowExpressions: false,
|
||||
},
|
||||
notes: [
|
||||
'Parses cURL command to populate fields',
|
||||
'Used in HTTP Request node',
|
||||
'One-time import feature',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Real-world examples for complex types
|
||||
*
|
||||
* These examples come from actual n8n workflows and demonstrate
|
||||
* correct usage patterns for complex property types.
|
||||
*
|
||||
* @constant
|
||||
*/
|
||||
export const COMPLEX_TYPE_EXAMPLES = {
|
||||
collection: {
|
||||
basic: {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
},
|
||||
nested: {
|
||||
user: {
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
},
|
||||
preferences: {
|
||||
theme: 'dark',
|
||||
notifications: true,
|
||||
},
|
||||
},
|
||||
withExpressions: {
|
||||
id: '{{ $json.userId }}',
|
||||
timestamp: '{{ $now }}',
|
||||
data: '{{ $json.payload }}',
|
||||
},
|
||||
},
|
||||
|
||||
fixedCollection: {
|
||||
httpHeaders: {
|
||||
headers: [
|
||||
{ name: 'Content-Type', value: 'application/json' },
|
||||
{ name: 'Authorization', value: 'Bearer {{ $credentials.token }}' },
|
||||
],
|
||||
},
|
||||
queryParameters: {
|
||||
queryParameters: [
|
||||
{ name: 'page', value: '1' },
|
||||
{ name: 'limit', value: '100' },
|
||||
],
|
||||
},
|
||||
multipleCollections: {
|
||||
headers: [{ name: 'Accept', value: 'application/json' }],
|
||||
queryParameters: [{ name: 'filter', value: 'active' }],
|
||||
},
|
||||
},
|
||||
|
||||
filter: {
|
||||
simple: {
|
||||
conditions: [
|
||||
{
|
||||
id: '1',
|
||||
leftValue: '{{ $json.status }}',
|
||||
operator: { type: 'string', operation: 'equals' },
|
||||
rightValue: 'active',
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
complex: {
|
||||
conditions: [
|
||||
{
|
||||
id: '1',
|
||||
leftValue: '{{ $json.age }}',
|
||||
operator: { type: 'number', operation: 'gt' },
|
||||
rightValue: 18,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
leftValue: '{{ $json.country }}',
|
||||
operator: { type: 'string', operation: 'equals' },
|
||||
rightValue: 'US',
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
},
|
||||
|
||||
resourceMapper: {
|
||||
autoMap: {
|
||||
mappingMode: 'autoMapInputData',
|
||||
value: {},
|
||||
},
|
||||
manual: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {
|
||||
firstName: '{{ $json.first_name }}',
|
||||
lastName: '{{ $json.last_name }}',
|
||||
email: '{{ $json.email_address }}',
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
assignmentCollection: {
|
||||
basic: {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'fullName',
|
||||
value: '{{ $json.firstName }} {{ $json.lastName }}',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
multiple: {
|
||||
assignments: [
|
||||
{ id: '1', name: 'userName', value: '{{ $json.name }}', type: 'string' },
|
||||
{ id: '2', name: 'userAge', value: '{{ $json.age }}', type: 'number' },
|
||||
{ id: '3', name: 'isActive', value: true, type: 'boolean' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -232,15 +232,45 @@ class BetterSQLiteAdapter implements DatabaseAdapter {
|
||||
*/
|
||||
class SQLJSAdapter implements DatabaseAdapter {
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private saveIntervalMs: number;
|
||||
private closed = false; // Prevent multiple close() calls
|
||||
|
||||
// Default save interval: 5 seconds (balance between data safety and performance)
|
||||
// Configurable via SQLJS_SAVE_INTERVAL_MS environment variable
|
||||
//
|
||||
// DATA LOSS WINDOW: Up to 5 seconds of database changes may be lost if process
|
||||
// crashes before scheduleSave() timer fires. This is acceptable because:
|
||||
// 1. close() calls saveToFile() immediately on graceful shutdown
|
||||
// 2. Docker/Kubernetes SIGTERM provides 30s for cleanup (more than enough)
|
||||
// 3. The alternative (100ms interval) caused 2.2GB memory leaks in production
|
||||
// 4. MCP server is primarily read-heavy (writes are rare)
|
||||
private static readonly DEFAULT_SAVE_INTERVAL_MS = 5000;
|
||||
|
||||
constructor(private db: any, private dbPath: string) {
|
||||
// Set up auto-save on changes
|
||||
this.scheduleSave();
|
||||
// Read save interval from environment or use default
|
||||
const envInterval = process.env.SQLJS_SAVE_INTERVAL_MS;
|
||||
this.saveIntervalMs = envInterval ? parseInt(envInterval, 10) : SQLJSAdapter.DEFAULT_SAVE_INTERVAL_MS;
|
||||
|
||||
// Validate interval (minimum 100ms, maximum 60000ms = 1 minute)
|
||||
if (isNaN(this.saveIntervalMs) || this.saveIntervalMs < 100 || this.saveIntervalMs > 60000) {
|
||||
logger.warn(
|
||||
`Invalid SQLJS_SAVE_INTERVAL_MS value: ${envInterval} (must be 100-60000ms), ` +
|
||||
`using default ${SQLJSAdapter.DEFAULT_SAVE_INTERVAL_MS}ms`
|
||||
);
|
||||
this.saveIntervalMs = SQLJSAdapter.DEFAULT_SAVE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
logger.debug(`SQLJSAdapter initialized with save interval: ${this.saveIntervalMs}ms`);
|
||||
|
||||
// NOTE: No initial save scheduled here (optimization)
|
||||
// Database is either:
|
||||
// 1. Loaded from existing file (already persisted), or
|
||||
// 2. New database (will be saved on first write operation)
|
||||
}
|
||||
|
||||
prepare(sql: string): PreparedStatement {
|
||||
const stmt = this.db.prepare(sql);
|
||||
this.scheduleSave();
|
||||
// Don't schedule save on prepare - only on actual writes (via SQLJSStatement.run())
|
||||
return new SQLJSStatement(stmt, () => this.scheduleSave());
|
||||
}
|
||||
|
||||
@@ -250,11 +280,18 @@ class SQLJSAdapter implements DatabaseAdapter {
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) {
|
||||
logger.debug('SQLJSAdapter already closed, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saveToFile();
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
this.db.close();
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
pragma(key: string, value?: any): any {
|
||||
@@ -301,19 +338,32 @@ class SQLJSAdapter implements DatabaseAdapter {
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
}
|
||||
|
||||
// Save after 100ms of inactivity
|
||||
|
||||
// Save after configured interval of inactivity (default: 5000ms)
|
||||
// This debouncing reduces memory churn from frequent buffer allocations
|
||||
//
|
||||
// NOTE: Under constant write load, saves may be delayed until writes stop.
|
||||
// This is acceptable because:
|
||||
// 1. MCP server is primarily read-heavy (node lookups, searches)
|
||||
// 2. Writes are rare (only during database rebuilds)
|
||||
// 3. close() saves immediately on shutdown, flushing any pending changes
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.saveToFile();
|
||||
}, 100);
|
||||
}, this.saveIntervalMs);
|
||||
}
|
||||
|
||||
private saveToFile(): void {
|
||||
try {
|
||||
// Export database to Uint8Array (2-5MB typical)
|
||||
const data = this.db.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fsSync.writeFileSync(this.dbPath, buffer);
|
||||
|
||||
// Write directly without Buffer.from() copy (saves 50% memory allocation)
|
||||
// writeFileSync accepts Uint8Array directly, no need for Buffer conversion
|
||||
fsSync.writeFileSync(this.dbPath, data);
|
||||
logger.debug(`Database saved to ${this.dbPath}`);
|
||||
|
||||
// Note: 'data' reference is automatically cleared when function exits
|
||||
// V8 GC will reclaim the Uint8Array once it's no longer referenced
|
||||
} catch (error) {
|
||||
logger.error('Failed to save database', error);
|
||||
}
|
||||
|
||||
@@ -462,4 +462,501 @@ export class NodeRepository {
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* VERSION MANAGEMENT METHODS
|
||||
* Methods for working with node_versions and version_property_changes tables
|
||||
*/
|
||||
|
||||
/**
|
||||
* Save a specific node version to the database
|
||||
*/
|
||||
saveNodeVersion(versionData: {
|
||||
nodeType: string;
|
||||
version: string;
|
||||
packageName: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
isCurrentMax?: boolean;
|
||||
propertiesSchema?: any;
|
||||
operations?: any;
|
||||
credentialsRequired?: any;
|
||||
outputs?: any;
|
||||
minimumN8nVersion?: string;
|
||||
breakingChanges?: any[];
|
||||
deprecatedProperties?: string[];
|
||||
addedProperties?: string[];
|
||||
releasedAt?: Date;
|
||||
}): void {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO node_versions (
|
||||
node_type, version, package_name, display_name, description,
|
||||
category, is_current_max, properties_schema, operations,
|
||||
credentials_required, outputs, minimum_n8n_version,
|
||||
breaking_changes, deprecated_properties, added_properties,
|
||||
released_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
versionData.nodeType,
|
||||
versionData.version,
|
||||
versionData.packageName,
|
||||
versionData.displayName,
|
||||
versionData.description || null,
|
||||
versionData.category || null,
|
||||
versionData.isCurrentMax ? 1 : 0,
|
||||
versionData.propertiesSchema ? JSON.stringify(versionData.propertiesSchema) : null,
|
||||
versionData.operations ? JSON.stringify(versionData.operations) : null,
|
||||
versionData.credentialsRequired ? JSON.stringify(versionData.credentialsRequired) : null,
|
||||
versionData.outputs ? JSON.stringify(versionData.outputs) : null,
|
||||
versionData.minimumN8nVersion || null,
|
||||
versionData.breakingChanges ? JSON.stringify(versionData.breakingChanges) : null,
|
||||
versionData.deprecatedProperties ? JSON.stringify(versionData.deprecatedProperties) : null,
|
||||
versionData.addedProperties ? JSON.stringify(versionData.addedProperties) : null,
|
||||
versionData.releasedAt || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available versions for a specific node type
|
||||
*/
|
||||
getNodeVersions(nodeType: string): any[] {
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM node_versions
|
||||
WHERE node_type = ?
|
||||
ORDER BY version DESC
|
||||
`).all(normalizedType) as any[];
|
||||
|
||||
return rows.map(row => this.parseNodeVersionRow(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest (current max) version for a node type
|
||||
*/
|
||||
getLatestNodeVersion(nodeType: string): any | null {
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM node_versions
|
||||
WHERE node_type = ? AND is_current_max = 1
|
||||
LIMIT 1
|
||||
`).get(normalizedType) as any;
|
||||
|
||||
if (!row) return null;
|
||||
return this.parseNodeVersionRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific version of a node
|
||||
*/
|
||||
getNodeVersion(nodeType: string, version: string): any | null {
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM node_versions
|
||||
WHERE node_type = ? AND version = ?
|
||||
`).get(normalizedType, version) as any;
|
||||
|
||||
if (!row) return null;
|
||||
return this.parseNodeVersionRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a property change between versions
|
||||
*/
|
||||
savePropertyChange(changeData: {
|
||||
nodeType: string;
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
propertyName: string;
|
||||
changeType: 'added' | 'removed' | 'renamed' | 'type_changed' | 'requirement_changed' | 'default_changed';
|
||||
isBreaking?: boolean;
|
||||
oldValue?: string;
|
||||
newValue?: string;
|
||||
migrationHint?: string;
|
||||
autoMigratable?: boolean;
|
||||
migrationStrategy?: any;
|
||||
severity?: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
}): void {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO version_property_changes (
|
||||
node_type, from_version, to_version, property_name, change_type,
|
||||
is_breaking, old_value, new_value, migration_hint, auto_migratable,
|
||||
migration_strategy, severity
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
changeData.nodeType,
|
||||
changeData.fromVersion,
|
||||
changeData.toVersion,
|
||||
changeData.propertyName,
|
||||
changeData.changeType,
|
||||
changeData.isBreaking ? 1 : 0,
|
||||
changeData.oldValue || null,
|
||||
changeData.newValue || null,
|
||||
changeData.migrationHint || null,
|
||||
changeData.autoMigratable ? 1 : 0,
|
||||
changeData.migrationStrategy ? JSON.stringify(changeData.migrationStrategy) : null,
|
||||
changeData.severity || 'MEDIUM'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get property changes between two versions
|
||||
*/
|
||||
getPropertyChanges(nodeType: string, fromVersion: string, toVersion: string): any[] {
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM version_property_changes
|
||||
WHERE node_type = ? AND from_version = ? AND to_version = ?
|
||||
ORDER BY severity DESC, property_name
|
||||
`).all(normalizedType, fromVersion, toVersion) as any[];
|
||||
|
||||
return rows.map(row => this.parsePropertyChangeRow(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all breaking changes for upgrading from one version to another
|
||||
* Can handle multi-step upgrades (e.g., 1.0 -> 2.0 via 1.5)
|
||||
*/
|
||||
getBreakingChanges(nodeType: string, fromVersion: string, toVersion?: string): any[] {
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
|
||||
let sql = `
|
||||
SELECT * FROM version_property_changes
|
||||
WHERE node_type = ? AND is_breaking = 1
|
||||
`;
|
||||
const params: any[] = [normalizedType];
|
||||
|
||||
if (toVersion) {
|
||||
// Get changes between specific versions
|
||||
sql += ` AND from_version >= ? AND to_version <= ?`;
|
||||
params.push(fromVersion, toVersion);
|
||||
} else {
|
||||
// Get all breaking changes from this version onwards
|
||||
sql += ` AND from_version >= ?`;
|
||||
params.push(fromVersion);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY from_version, to_version, severity DESC`;
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as any[];
|
||||
return rows.map(row => this.parsePropertyChangeRow(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auto-migratable changes for a version upgrade
|
||||
*/
|
||||
getAutoMigratableChanges(nodeType: string, fromVersion: string, toVersion: string): any[] {
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM version_property_changes
|
||||
WHERE node_type = ?
|
||||
AND from_version = ?
|
||||
AND to_version = ?
|
||||
AND auto_migratable = 1
|
||||
ORDER BY severity DESC
|
||||
`).all(normalizedType, fromVersion, toVersion) as any[];
|
||||
|
||||
return rows.map(row => this.parsePropertyChangeRow(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a version upgrade path exists between two versions
|
||||
*/
|
||||
hasVersionUpgradePath(nodeType: string, fromVersion: string, toVersion: string): boolean {
|
||||
const versions = this.getNodeVersions(nodeType);
|
||||
if (versions.length === 0) return false;
|
||||
|
||||
// Check if both versions exist
|
||||
const fromExists = versions.some(v => v.version === fromVersion);
|
||||
const toExists = versions.some(v => v.version === toVersion);
|
||||
|
||||
return fromExists && toExists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of nodes with multiple versions
|
||||
*/
|
||||
getVersionedNodesCount(): number {
|
||||
const result = this.db.prepare(`
|
||||
SELECT COUNT(DISTINCT node_type) as count
|
||||
FROM node_versions
|
||||
`).get() as any;
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse node version row from database
|
||||
*/
|
||||
private parseNodeVersionRow(row: any): any {
|
||||
return {
|
||||
id: row.id,
|
||||
nodeType: row.node_type,
|
||||
version: row.version,
|
||||
packageName: row.package_name,
|
||||
displayName: row.display_name,
|
||||
description: row.description,
|
||||
category: row.category,
|
||||
isCurrentMax: Number(row.is_current_max) === 1,
|
||||
propertiesSchema: row.properties_schema ? this.safeJsonParse(row.properties_schema, []) : null,
|
||||
operations: row.operations ? this.safeJsonParse(row.operations, []) : null,
|
||||
credentialsRequired: row.credentials_required ? this.safeJsonParse(row.credentials_required, []) : null,
|
||||
outputs: row.outputs ? this.safeJsonParse(row.outputs, null) : null,
|
||||
minimumN8nVersion: row.minimum_n8n_version,
|
||||
breakingChanges: row.breaking_changes ? this.safeJsonParse(row.breaking_changes, []) : [],
|
||||
deprecatedProperties: row.deprecated_properties ? this.safeJsonParse(row.deprecated_properties, []) : [],
|
||||
addedProperties: row.added_properties ? this.safeJsonParse(row.added_properties, []) : [],
|
||||
releasedAt: row.released_at,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse property change row from database
|
||||
*/
|
||||
private parsePropertyChangeRow(row: any): any {
|
||||
return {
|
||||
id: row.id,
|
||||
nodeType: row.node_type,
|
||||
fromVersion: row.from_version,
|
||||
toVersion: row.to_version,
|
||||
propertyName: row.property_name,
|
||||
changeType: row.change_type,
|
||||
isBreaking: Number(row.is_breaking) === 1,
|
||||
oldValue: row.old_value,
|
||||
newValue: row.new_value,
|
||||
migrationHint: row.migration_hint,
|
||||
autoMigratable: Number(row.auto_migratable) === 1,
|
||||
migrationStrategy: row.migration_strategy ? this.safeJsonParse(row.migration_strategy, null) : null,
|
||||
severity: row.severity,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Workflow Versioning Methods
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Create a new workflow version (backup before modification)
|
||||
*/
|
||||
createWorkflowVersion(data: {
|
||||
workflowId: string;
|
||||
versionNumber: number;
|
||||
workflowName: string;
|
||||
workflowSnapshot: any;
|
||||
trigger: 'partial_update' | 'full_update' | 'autofix';
|
||||
operations?: any[];
|
||||
fixTypes?: string[];
|
||||
metadata?: any;
|
||||
}): number {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO workflow_versions (
|
||||
workflow_id, version_number, workflow_name, workflow_snapshot,
|
||||
trigger, operations, fix_types, metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
data.workflowId,
|
||||
data.versionNumber,
|
||||
data.workflowName,
|
||||
JSON.stringify(data.workflowSnapshot),
|
||||
data.trigger,
|
||||
data.operations ? JSON.stringify(data.operations) : null,
|
||||
data.fixTypes ? JSON.stringify(data.fixTypes) : null,
|
||||
data.metadata ? JSON.stringify(data.metadata) : null
|
||||
);
|
||||
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow versions ordered by version number (newest first)
|
||||
*/
|
||||
getWorkflowVersions(workflowId: string, limit?: number): any[] {
|
||||
let sql = `
|
||||
SELECT * FROM workflow_versions
|
||||
WHERE workflow_id = ?
|
||||
ORDER BY version_number DESC
|
||||
`;
|
||||
|
||||
if (limit) {
|
||||
sql += ` LIMIT ?`;
|
||||
const rows = this.db.prepare(sql).all(workflowId, limit) as any[];
|
||||
return rows.map(row => this.parseWorkflowVersionRow(row));
|
||||
}
|
||||
|
||||
const rows = this.db.prepare(sql).all(workflowId) as any[];
|
||||
return rows.map(row => this.parseWorkflowVersionRow(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific workflow version by ID
|
||||
*/
|
||||
getWorkflowVersion(versionId: number): any | null {
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM workflow_versions WHERE id = ?
|
||||
`).get(versionId) as any;
|
||||
|
||||
if (!row) return null;
|
||||
return this.parseWorkflowVersionRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest workflow version for a workflow
|
||||
*/
|
||||
getLatestWorkflowVersion(workflowId: string): any | null {
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM workflow_versions
|
||||
WHERE workflow_id = ?
|
||||
ORDER BY version_number DESC
|
||||
LIMIT 1
|
||||
`).get(workflowId) as any;
|
||||
|
||||
if (!row) return null;
|
||||
return this.parseWorkflowVersionRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific workflow version
|
||||
*/
|
||||
deleteWorkflowVersion(versionId: number): void {
|
||||
this.db.prepare(`
|
||||
DELETE FROM workflow_versions WHERE id = ?
|
||||
`).run(versionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all versions for a specific workflow
|
||||
*/
|
||||
deleteWorkflowVersionsByWorkflowId(workflowId: string): number {
|
||||
const result = this.db.prepare(`
|
||||
DELETE FROM workflow_versions WHERE workflow_id = ?
|
||||
`).run(workflowId);
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old workflow versions, keeping only the most recent N versions
|
||||
* Returns number of versions deleted
|
||||
*/
|
||||
pruneWorkflowVersions(workflowId: string, keepCount: number): number {
|
||||
// Get all versions ordered by version_number DESC
|
||||
const versions = this.db.prepare(`
|
||||
SELECT id FROM workflow_versions
|
||||
WHERE workflow_id = ?
|
||||
ORDER BY version_number DESC
|
||||
`).all(workflowId) as any[];
|
||||
|
||||
// If we have fewer versions than keepCount, no pruning needed
|
||||
if (versions.length <= keepCount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get IDs of versions to delete (all except the most recent keepCount)
|
||||
const idsToDelete = versions.slice(keepCount).map(v => v.id);
|
||||
|
||||
if (idsToDelete.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Delete old versions
|
||||
const placeholders = idsToDelete.map(() => '?').join(',');
|
||||
const result = this.db.prepare(`
|
||||
DELETE FROM workflow_versions WHERE id IN (${placeholders})
|
||||
`).run(...idsToDelete);
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate the entire workflow_versions table
|
||||
* Returns number of rows deleted
|
||||
*/
|
||||
truncateWorkflowVersions(): number {
|
||||
const result = this.db.prepare(`
|
||||
DELETE FROM workflow_versions
|
||||
`).run();
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of versions for a specific workflow
|
||||
*/
|
||||
getWorkflowVersionCount(workflowId: string): number {
|
||||
const result = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM workflow_versions WHERE workflow_id = ?
|
||||
`).get(workflowId) as any;
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics for workflow versions
|
||||
*/
|
||||
getVersionStorageStats(): any {
|
||||
// Total versions
|
||||
const totalResult = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM workflow_versions
|
||||
`).get() as any;
|
||||
|
||||
// Total size (approximate - sum of JSON lengths)
|
||||
const sizeResult = this.db.prepare(`
|
||||
SELECT SUM(LENGTH(workflow_snapshot)) as total_size FROM workflow_versions
|
||||
`).get() as any;
|
||||
|
||||
// Per-workflow breakdown
|
||||
const byWorkflow = this.db.prepare(`
|
||||
SELECT
|
||||
workflow_id,
|
||||
workflow_name,
|
||||
COUNT(*) as version_count,
|
||||
SUM(LENGTH(workflow_snapshot)) as total_size,
|
||||
MAX(created_at) as last_backup
|
||||
FROM workflow_versions
|
||||
GROUP BY workflow_id
|
||||
ORDER BY version_count DESC
|
||||
`).all() as any[];
|
||||
|
||||
return {
|
||||
totalVersions: totalResult.count,
|
||||
totalSize: sizeResult.total_size || 0,
|
||||
byWorkflow: byWorkflow.map(row => ({
|
||||
workflowId: row.workflow_id,
|
||||
workflowName: row.workflow_name,
|
||||
versionCount: row.version_count,
|
||||
totalSize: row.total_size,
|
||||
lastBackup: row.last_backup
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse workflow version row from database
|
||||
*/
|
||||
private parseWorkflowVersionRow(row: any): any {
|
||||
return {
|
||||
id: row.id,
|
||||
workflowId: row.workflow_id,
|
||||
versionNumber: row.version_number,
|
||||
workflowName: row.workflow_name,
|
||||
workflowSnapshot: this.safeJsonParse(row.workflow_snapshot, null),
|
||||
trigger: row.trigger,
|
||||
operations: row.operations ? this.safeJsonParse(row.operations, null) : null,
|
||||
fixTypes: row.fix_types ? this.safeJsonParse(row.fix_types, null) : null,
|
||||
metadata: row.metadata ? this.safeJsonParse(row.metadata, null) : null,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -144,4 +144,93 @@ ORDER BY node_type, rank;
|
||||
|
||||
-- Note: Template FTS5 tables are created conditionally at runtime if FTS5 is supported
|
||||
-- See template-repository.ts initializeFTS5() method
|
||||
-- Node FTS5 table (nodes_fts) is created above during schema initialization
|
||||
-- Node FTS5 table (nodes_fts) is created above during schema initialization
|
||||
|
||||
-- Node versions table for tracking all available versions of each node
|
||||
-- Enables version upgrade detection and migration
|
||||
CREATE TABLE IF NOT EXISTS node_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_type TEXT NOT NULL, -- e.g., "n8n-nodes-base.executeWorkflow"
|
||||
version TEXT NOT NULL, -- e.g., "1.0", "1.1", "2.0"
|
||||
package_name TEXT NOT NULL, -- e.g., "n8n-nodes-base"
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
is_current_max INTEGER DEFAULT 0, -- 1 if this is the latest version
|
||||
properties_schema TEXT, -- JSON schema for this specific version
|
||||
operations TEXT, -- JSON array of operations for this version
|
||||
credentials_required TEXT, -- JSON array of required credentials
|
||||
outputs TEXT, -- JSON array of output definitions
|
||||
minimum_n8n_version TEXT, -- Minimum n8n version required (e.g., "1.0.0")
|
||||
breaking_changes TEXT, -- JSON array of breaking changes from previous version
|
||||
deprecated_properties TEXT, -- JSON array of removed/deprecated properties
|
||||
added_properties TEXT, -- JSON array of newly added properties
|
||||
released_at DATETIME, -- When this version was released
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(node_type, version),
|
||||
FOREIGN KEY (node_type) REFERENCES nodes(node_type) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes for version queries
|
||||
CREATE INDEX IF NOT EXISTS idx_version_node_type ON node_versions(node_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_version_current_max ON node_versions(is_current_max);
|
||||
CREATE INDEX IF NOT EXISTS idx_version_composite ON node_versions(node_type, version);
|
||||
|
||||
-- Version property changes for detailed migration tracking
|
||||
-- Records specific property-level changes between versions
|
||||
CREATE TABLE IF NOT EXISTS version_property_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_type TEXT NOT NULL,
|
||||
from_version TEXT NOT NULL, -- Version where change occurred (e.g., "1.0")
|
||||
to_version TEXT NOT NULL, -- Target version (e.g., "1.1")
|
||||
property_name TEXT NOT NULL, -- Property path (e.g., "parameters.inputFieldMapping")
|
||||
change_type TEXT NOT NULL CHECK(change_type IN (
|
||||
'added', -- Property added (may be required)
|
||||
'removed', -- Property removed/deprecated
|
||||
'renamed', -- Property renamed
|
||||
'type_changed', -- Property type changed
|
||||
'requirement_changed', -- Required → Optional or vice versa
|
||||
'default_changed' -- Default value changed
|
||||
)),
|
||||
is_breaking INTEGER DEFAULT 0, -- 1 if this is a breaking change
|
||||
old_value TEXT, -- For renamed/type_changed: old property name or type
|
||||
new_value TEXT, -- For renamed/type_changed: new property name or type
|
||||
migration_hint TEXT, -- Human-readable migration guidance
|
||||
auto_migratable INTEGER DEFAULT 0, -- 1 if can be automatically migrated
|
||||
migration_strategy TEXT, -- JSON: strategy for auto-migration
|
||||
severity TEXT CHECK(severity IN ('LOW', 'MEDIUM', 'HIGH')), -- Impact severity
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (node_type, from_version) REFERENCES node_versions(node_type, version) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes for property change queries
|
||||
CREATE INDEX IF NOT EXISTS idx_prop_changes_node ON version_property_changes(node_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_prop_changes_versions ON version_property_changes(node_type, from_version, to_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_prop_changes_breaking ON version_property_changes(is_breaking);
|
||||
CREATE INDEX IF NOT EXISTS idx_prop_changes_auto ON version_property_changes(auto_migratable);
|
||||
|
||||
-- Workflow versions table for rollback and version history tracking
|
||||
-- Stores full workflow snapshots before modifications for guaranteed reversibility
|
||||
-- Auto-prunes to 10 versions per workflow to prevent memory leaks
|
||||
CREATE TABLE IF NOT EXISTS workflow_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workflow_id TEXT NOT NULL, -- n8n workflow ID
|
||||
version_number INTEGER NOT NULL, -- Incremental version number (1, 2, 3...)
|
||||
workflow_name TEXT NOT NULL, -- Workflow name at time of backup
|
||||
workflow_snapshot TEXT NOT NULL, -- Full workflow JSON before modification
|
||||
trigger TEXT NOT NULL CHECK(trigger IN (
|
||||
'partial_update', -- Created by n8n_update_partial_workflow
|
||||
'full_update', -- Created by n8n_update_full_workflow
|
||||
'autofix' -- Created by n8n_autofix_workflow
|
||||
)),
|
||||
operations TEXT, -- JSON array of diff operations (if partial update)
|
||||
fix_types TEXT, -- JSON array of fix types (if autofix)
|
||||
metadata TEXT, -- Additional context (JSON)
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workflow_id, version_number)
|
||||
);
|
||||
|
||||
-- Indexes for workflow version queries
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow_id ON workflow_versions(workflow_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_versions_created_at ON workflow_versions(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_versions_trigger ON workflow_versions(trigger);
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
STANDARD_PROTOCOL_VERSION
|
||||
} from './utils/protocol-version';
|
||||
import { InstanceContext, validateInstanceContext } from './types/instance-context';
|
||||
import { SessionState } from './types/session-state';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -71,6 +72,30 @@ function extractMultiTenantHeaders(req: express.Request): MultiTenantHeaders {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Security logging helper for audit trails
|
||||
* Provides structured logging for security-relevant events
|
||||
*/
|
||||
function logSecurityEvent(
|
||||
event: 'session_export' | 'session_restore' | 'session_restore_failed' | 'max_sessions_reached',
|
||||
details: {
|
||||
sessionId?: string;
|
||||
reason?: string;
|
||||
count?: number;
|
||||
instanceId?: string;
|
||||
}
|
||||
): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
event,
|
||||
...details
|
||||
};
|
||||
|
||||
// Log to standard logger with [SECURITY] prefix for easy filtering
|
||||
logger.info(`[SECURITY] ${event}`, logEntry);
|
||||
}
|
||||
|
||||
export class SingleSessionHTTPServer {
|
||||
// Map to store transports by session ID (following SDK pattern)
|
||||
private transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
||||
@@ -155,17 +180,22 @@ export class SingleSessionHTTPServer {
|
||||
*/
|
||||
private async removeSession(sessionId: string, reason: string): Promise<void> {
|
||||
try {
|
||||
// Close transport if exists
|
||||
if (this.transports[sessionId]) {
|
||||
await this.transports[sessionId].close();
|
||||
delete this.transports[sessionId];
|
||||
}
|
||||
|
||||
// Remove server, metadata, and context
|
||||
// Store reference to transport before deletion
|
||||
const transport = this.transports[sessionId];
|
||||
|
||||
// Delete transport FIRST to prevent onclose handler from triggering recursion
|
||||
// This breaks the circular reference: removeSession -> close -> onclose -> removeSession
|
||||
delete this.transports[sessionId];
|
||||
delete this.servers[sessionId];
|
||||
delete this.sessionMetadata[sessionId];
|
||||
delete this.sessionContexts[sessionId];
|
||||
|
||||
|
||||
// Close transport AFTER deletion
|
||||
// When onclose handler fires, it won't find the transport anymore
|
||||
if (transport) {
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
logger.info('Session removed', { sessionId, reason });
|
||||
} catch (error) {
|
||||
logger.warn('Error removing session', { sessionId, reason, error });
|
||||
@@ -188,11 +218,22 @@ export class SingleSessionHTTPServer {
|
||||
|
||||
/**
|
||||
* Validate session ID format
|
||||
*
|
||||
* Accepts any non-empty string to support various MCP clients:
|
||||
* - UUIDv4 (internal n8n-mcp format)
|
||||
* - instance-{userId}-{hash}-{uuid} (multi-tenant format)
|
||||
* - Custom formats from mcp-remote and other proxies
|
||||
*
|
||||
* Security: Session validation happens via lookup in this.transports,
|
||||
* not format validation. This ensures compatibility with all MCP clients.
|
||||
*
|
||||
* @param sessionId - Session identifier from MCP client
|
||||
* @returns true if valid, false otherwise
|
||||
*/
|
||||
private isValidSessionId(sessionId: string): boolean {
|
||||
// UUID v4 format validation
|
||||
const uuidv4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidv4Regex.test(sessionId);
|
||||
// Accept any non-empty string as session ID
|
||||
// This ensures compatibility with all MCP clients and proxies
|
||||
return Boolean(sessionId && sessionId.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -671,7 +712,20 @@ export class SingleSessionHTTPServer {
|
||||
if (!this.session) return true;
|
||||
return Date.now() - this.session.lastAccess.getTime() > this.sessionTimeout;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a specific session is expired based on sessionId
|
||||
* Used for multi-session expiration checks during export/restore
|
||||
*
|
||||
* @param sessionId - The session ID to check
|
||||
* @returns true if session is expired or doesn't exist
|
||||
*/
|
||||
private isSessionExpired(sessionId: string): boolean {
|
||||
const metadata = this.sessionMetadata[sessionId];
|
||||
if (!metadata) return true;
|
||||
return Date.now() - metadata.lastAccess.getTime() > this.sessionTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP server
|
||||
*/
|
||||
@@ -1390,6 +1444,197 @@ export class SingleSessionHTTPServer {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all active session state for persistence
|
||||
*
|
||||
* Used by multi-tenant backends to dump sessions before container restart.
|
||||
* This method exports the minimal state needed to restore sessions after
|
||||
* a restart: session metadata (timing) and instance context (credentials).
|
||||
*
|
||||
* Transport and server objects are NOT persisted - they will be recreated
|
||||
* on the first request after restore.
|
||||
*
|
||||
* SECURITY WARNING: The exported data contains plaintext n8n API keys.
|
||||
* The downstream application MUST encrypt this data before persisting to disk.
|
||||
*
|
||||
* @returns Array of session state objects, excluding expired sessions
|
||||
*
|
||||
* @example
|
||||
* // Before shutdown
|
||||
* const sessions = server.exportSessionState();
|
||||
* await saveToEncryptedStorage(sessions);
|
||||
*/
|
||||
public exportSessionState(): SessionState[] {
|
||||
const sessions: SessionState[] = [];
|
||||
const seenSessionIds = new Set<string>();
|
||||
|
||||
// Iterate over all sessions with metadata (source of truth for active sessions)
|
||||
for (const sessionId of Object.keys(this.sessionMetadata)) {
|
||||
// Check for duplicates (defensive programming)
|
||||
if (seenSessionIds.has(sessionId)) {
|
||||
logger.warn(`Duplicate sessionId detected during export: ${sessionId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip expired sessions - they're not worth persisting
|
||||
if (this.isSessionExpired(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = this.sessionMetadata[sessionId];
|
||||
const context = this.sessionContexts[sessionId];
|
||||
|
||||
// Skip sessions without context - these can't be restored meaningfully
|
||||
// (Context is required to reconnect to the correct n8n instance)
|
||||
if (!context || !context.n8nApiUrl || !context.n8nApiKey) {
|
||||
logger.debug(`Skipping session ${sessionId} - missing required context`);
|
||||
continue;
|
||||
}
|
||||
|
||||
seenSessionIds.add(sessionId);
|
||||
sessions.push({
|
||||
sessionId,
|
||||
metadata: {
|
||||
createdAt: metadata.createdAt.toISOString(),
|
||||
lastAccess: metadata.lastAccess.toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: context.n8nApiUrl,
|
||||
n8nApiKey: context.n8nApiKey,
|
||||
instanceId: context.instanceId || sessionId, // Use sessionId as fallback
|
||||
sessionId: context.sessionId,
|
||||
metadata: context.metadata
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Exported ${sessions.length} session(s) for persistence`);
|
||||
logSecurityEvent('session_export', { count: sessions.length });
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore session state from previously exported data
|
||||
*
|
||||
* Used by multi-tenant backends to restore sessions after container restart.
|
||||
* This method restores only the session metadata and instance context.
|
||||
* Transport and server objects will be recreated on the first request.
|
||||
*
|
||||
* Restored sessions are "dormant" until a client makes a request, at which
|
||||
* point the transport and server will be initialized normally.
|
||||
*
|
||||
* @param sessions - Array of session state objects from exportSessionState()
|
||||
* @returns Number of sessions successfully restored
|
||||
*
|
||||
* @example
|
||||
* // After startup
|
||||
* const sessions = await loadFromEncryptedStorage();
|
||||
* const count = server.restoreSessionState(sessions);
|
||||
* console.log(`Restored ${count} sessions`);
|
||||
*/
|
||||
public restoreSessionState(sessions: SessionState[]): number {
|
||||
let restoredCount = 0;
|
||||
|
||||
for (const sessionState of sessions) {
|
||||
try {
|
||||
// Skip null or invalid session objects
|
||||
if (!sessionState || typeof sessionState !== 'object' || !sessionState.sessionId) {
|
||||
logger.warn('Skipping invalid session state object');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've hit the MAX_SESSIONS limit (check real-time count)
|
||||
if (Object.keys(this.sessionMetadata).length >= MAX_SESSIONS) {
|
||||
logger.warn(
|
||||
`Reached MAX_SESSIONS limit (${MAX_SESSIONS}), skipping remaining sessions`
|
||||
);
|
||||
logSecurityEvent('max_sessions_reached', { count: MAX_SESSIONS });
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip if session already exists (duplicate sessionId)
|
||||
if (this.sessionMetadata[sessionState.sessionId]) {
|
||||
logger.debug(`Skipping session ${sessionState.sessionId} - already exists`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse and validate dates first
|
||||
const createdAt = new Date(sessionState.metadata.createdAt);
|
||||
const lastAccess = new Date(sessionState.metadata.lastAccess);
|
||||
|
||||
if (isNaN(createdAt.getTime()) || isNaN(lastAccess.getTime())) {
|
||||
logger.warn(
|
||||
`Skipping session ${sessionState.sessionId} - invalid date format`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate session isn't expired
|
||||
const age = Date.now() - lastAccess.getTime();
|
||||
if (age > this.sessionTimeout) {
|
||||
logger.debug(
|
||||
`Skipping session ${sessionState.sessionId} - expired (age: ${Math.round(age / 1000)}s)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate context exists (TypeScript null narrowing)
|
||||
if (!sessionState.context) {
|
||||
logger.warn(`Skipping session ${sessionState.sessionId} - missing context`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate context structure using existing validation
|
||||
const validation = validateInstanceContext(sessionState.context);
|
||||
if (!validation.valid) {
|
||||
const reason = validation.errors?.join(', ') || 'invalid context';
|
||||
logger.warn(
|
||||
`Skipping session ${sessionState.sessionId} - invalid context: ${reason}`
|
||||
);
|
||||
logSecurityEvent('session_restore_failed', {
|
||||
sessionId: sessionState.sessionId,
|
||||
reason
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Restore session metadata
|
||||
this.sessionMetadata[sessionState.sessionId] = {
|
||||
createdAt,
|
||||
lastAccess
|
||||
};
|
||||
|
||||
// Restore session context
|
||||
this.sessionContexts[sessionState.sessionId] = {
|
||||
n8nApiUrl: sessionState.context.n8nApiUrl,
|
||||
n8nApiKey: sessionState.context.n8nApiKey,
|
||||
instanceId: sessionState.context.instanceId,
|
||||
sessionId: sessionState.context.sessionId,
|
||||
metadata: sessionState.context.metadata
|
||||
};
|
||||
|
||||
logger.debug(`Restored session ${sessionState.sessionId}`);
|
||||
logSecurityEvent('session_restore', {
|
||||
sessionId: sessionState.sessionId,
|
||||
instanceId: sessionState.context.instanceId
|
||||
});
|
||||
restoredCount++;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restore session ${sessionState.sessionId}:`, error);
|
||||
logSecurityEvent('session_restore_failed', {
|
||||
sessionId: sessionState.sessionId,
|
||||
reason: error instanceof Error ? error.message : 'unknown error'
|
||||
});
|
||||
// Continue with next session - don't let one failure break the entire restore
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Restored ${restoredCount}/${sessions.length} session(s) from persistence`
|
||||
);
|
||||
return restoredCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Start if called directly
|
||||
|
||||
@@ -23,6 +23,17 @@ import {
|
||||
|
||||
dotenv.config();
|
||||
|
||||
/**
|
||||
* MCP tool response format with optional structured content
|
||||
*/
|
||||
interface MCPToolResponse {
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
structuredContent?: unknown;
|
||||
}
|
||||
|
||||
let expressServer: any;
|
||||
let authToken: string | null = null;
|
||||
|
||||
@@ -401,19 +412,46 @@ export async function startFixedHTTPServer() {
|
||||
// Delegate to the MCP server
|
||||
const toolName = jsonRpcRequest.params?.name;
|
||||
const toolArgs = jsonRpcRequest.params?.arguments || {};
|
||||
|
||||
|
||||
try {
|
||||
const result = await mcpServer.executeTool(toolName, toolArgs);
|
||||
|
||||
// Convert result to JSON text for content field
|
||||
let responseText = JSON.stringify(result, null, 2);
|
||||
|
||||
// Build MCP-compliant response with structuredContent for validation tools
|
||||
const mcpResult: MCPToolResponse = {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: responseText
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Add structuredContent for validation tools (they have outputSchema)
|
||||
// Apply 1MB safety limit to prevent memory issues (matches STDIO server behavior)
|
||||
if (toolName.startsWith('validate_')) {
|
||||
const resultSize = responseText.length;
|
||||
|
||||
if (resultSize > 1000000) {
|
||||
// Response is too large - truncate and warn
|
||||
logger.warn(
|
||||
`Validation tool ${toolName} response is very large (${resultSize} chars). ` +
|
||||
`Truncating for HTTP transport safety.`
|
||||
);
|
||||
mcpResult.content[0].text = responseText.substring(0, 999000) +
|
||||
'\n\n[Response truncated due to size limits]';
|
||||
// Don't include structuredContent for truncated responses
|
||||
} else {
|
||||
// Normal case - include structured content for MCP protocol compliance
|
||||
mcpResult.structuredContent = result;
|
||||
}
|
||||
}
|
||||
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2)
|
||||
}
|
||||
]
|
||||
},
|
||||
result: mcpResult,
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
19
src/index.ts
19
src/index.ts
@@ -10,6 +10,25 @@ export { SingleSessionHTTPServer } from './http-server-single-session';
|
||||
export { ConsoleManager } from './utils/console-manager';
|
||||
export { N8NDocumentationMCPServer } from './mcp/server';
|
||||
|
||||
// Type exports for multi-tenant and library usage
|
||||
export type {
|
||||
InstanceContext
|
||||
} from './types/instance-context';
|
||||
export {
|
||||
validateInstanceContext,
|
||||
isInstanceContext
|
||||
} from './types/instance-context';
|
||||
export type {
|
||||
SessionState
|
||||
} from './types/session-state';
|
||||
|
||||
// Re-export MCP SDK types for convenience
|
||||
export type {
|
||||
Tool,
|
||||
CallToolResult,
|
||||
ListToolsResult
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
// Default export for convenience
|
||||
import N8NMCPEngine from './mcp-engine';
|
||||
export default N8NMCPEngine;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Request, Response } from 'express';
|
||||
import { SingleSessionHTTPServer } from './http-server-single-session';
|
||||
import { logger } from './utils/logger';
|
||||
import { InstanceContext } from './types/instance-context';
|
||||
import { SessionState } from './types/session-state';
|
||||
|
||||
export interface EngineHealth {
|
||||
status: 'healthy' | 'unhealthy';
|
||||
@@ -97,7 +98,7 @@ export class N8NMCPEngine {
|
||||
total: Math.round(memoryUsage.heapTotal / 1024 / 1024),
|
||||
unit: 'MB'
|
||||
},
|
||||
version: '2.3.2'
|
||||
version: '2.24.1'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Health check failed:', error);
|
||||
@@ -106,7 +107,7 @@ export class N8NMCPEngine {
|
||||
uptime: 0,
|
||||
sessionActive: false,
|
||||
memoryUsage: { used: 0, total: 0, unit: 'MB' },
|
||||
version: '2.3.2'
|
||||
version: '2.24.1'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -118,10 +119,58 @@ export class N8NMCPEngine {
|
||||
getSessionInfo(): { active: boolean; sessionId?: string; age?: number } {
|
||||
return this.server.getSessionInfo();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Export all active session state for persistence
|
||||
*
|
||||
* Used by multi-tenant backends to dump sessions before container restart.
|
||||
* Returns an array of session state objects containing metadata and credentials.
|
||||
*
|
||||
* SECURITY WARNING: Exported data contains plaintext n8n API keys.
|
||||
* Encrypt before persisting to disk.
|
||||
*
|
||||
* @returns Array of session state objects
|
||||
*
|
||||
* @example
|
||||
* // Before shutdown
|
||||
* const sessions = engine.exportSessionState();
|
||||
* await saveToEncryptedStorage(sessions);
|
||||
*/
|
||||
exportSessionState(): SessionState[] {
|
||||
if (!this.server) {
|
||||
logger.warn('Cannot export sessions: server not initialized');
|
||||
return [];
|
||||
}
|
||||
return this.server.exportSessionState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore session state from previously exported data
|
||||
*
|
||||
* Used by multi-tenant backends to restore sessions after container restart.
|
||||
* Restores session metadata and instance context. Transports/servers are
|
||||
* recreated on first request.
|
||||
*
|
||||
* @param sessions - Array of session state objects from exportSessionState()
|
||||
* @returns Number of sessions successfully restored
|
||||
*
|
||||
* @example
|
||||
* // After startup
|
||||
* const sessions = await loadFromEncryptedStorage();
|
||||
* const count = engine.restoreSessionState(sessions);
|
||||
* console.log(`Restored ${count} sessions`);
|
||||
*/
|
||||
restoreSessionState(sessions: SessionState[]): number {
|
||||
if (!this.server) {
|
||||
logger.warn('Cannot restore sessions: server not initialized');
|
||||
return 0;
|
||||
}
|
||||
return this.server.restoreSessionState(sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown for service lifecycle
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* process.on('SIGTERM', async () => {
|
||||
* await engine.shutdown();
|
||||
|
||||
@@ -31,6 +31,7 @@ import { InstanceContext, validateInstanceContext } from '../types/instance-cont
|
||||
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
|
||||
import { WorkflowAutoFixer, AutoFixConfig } from '../services/workflow-auto-fixer';
|
||||
import { ExpressionFormatValidator, ExpressionFormatIssue } from '../services/expression-format-validator';
|
||||
import { WorkflowVersioningService } from '../services/workflow-versioning-service';
|
||||
import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
|
||||
import { telemetry } from '../telemetry';
|
||||
import {
|
||||
@@ -363,6 +364,8 @@ const updateWorkflowSchema = z.object({
|
||||
nodes: z.array(z.any()).optional(),
|
||||
connections: z.record(z.any()).optional(),
|
||||
settings: z.any().optional(),
|
||||
createBackup: z.boolean().optional(),
|
||||
intent: z.string().optional(),
|
||||
});
|
||||
|
||||
const listWorkflowsSchema = z.object({
|
||||
@@ -415,6 +418,17 @@ const listExecutionsSchema = z.object({
|
||||
includeData: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const workflowVersionsSchema = z.object({
|
||||
mode: z.enum(['list', 'get', 'rollback', 'delete', 'prune', 'truncate']),
|
||||
workflowId: z.string().optional(),
|
||||
versionId: z.number().optional(),
|
||||
limit: z.number().default(10).optional(),
|
||||
validateBefore: z.boolean().default(true).optional(),
|
||||
deleteAll: z.boolean().default(false).optional(),
|
||||
maxVersions: z.number().default(10).optional(),
|
||||
confirmTruncate: z.boolean().default(false).optional(),
|
||||
});
|
||||
|
||||
// Workflow Management Handlers
|
||||
|
||||
export async function handleCreateWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
@@ -682,16 +696,51 @@ export async function handleGetWorkflowMinimal(args: unknown, context?: Instance
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleUpdateWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
export async function handleUpdateWorkflow(
|
||||
args: unknown,
|
||||
repository: NodeRepository,
|
||||
context?: InstanceContext
|
||||
): Promise<McpToolResponse> {
|
||||
const startTime = Date.now();
|
||||
const sessionId = `mutation_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
let workflowBefore: any = null;
|
||||
let userIntent = 'Full workflow update';
|
||||
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = updateWorkflowSchema.parse(args);
|
||||
const { id, ...updateData } = input;
|
||||
const { id, createBackup, intent, ...updateData } = input;
|
||||
userIntent = intent || 'Full workflow update';
|
||||
|
||||
// If nodes/connections are being updated, validate the structure
|
||||
if (updateData.nodes || updateData.connections) {
|
||||
// Always fetch current workflow for validation (need all fields like name)
|
||||
const current = await client.getWorkflow(id);
|
||||
workflowBefore = JSON.parse(JSON.stringify(current));
|
||||
|
||||
// Create backup before modifying workflow (default: true)
|
||||
if (createBackup !== false) {
|
||||
try {
|
||||
const versioningService = new WorkflowVersioningService(repository, client);
|
||||
const backupResult = await versioningService.createBackup(id, current, {
|
||||
trigger: 'full_update'
|
||||
});
|
||||
|
||||
logger.info('Workflow backup created', {
|
||||
workflowId: id,
|
||||
versionId: backupResult.versionId,
|
||||
versionNumber: backupResult.versionNumber,
|
||||
pruned: backupResult.pruned
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.warn('Failed to create workflow backup', {
|
||||
workflowId: id,
|
||||
error: error.message
|
||||
});
|
||||
// Continue with update even if backup fails (non-blocking)
|
||||
}
|
||||
}
|
||||
|
||||
const fullWorkflow = {
|
||||
...current,
|
||||
...updateData
|
||||
@@ -707,16 +756,49 @@ export async function handleUpdateWorkflow(args: unknown, context?: InstanceCont
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update workflow
|
||||
const workflow = await client.updateWorkflow(id, updateData);
|
||||
|
||||
|
||||
// Track successful mutation
|
||||
if (workflowBefore) {
|
||||
trackWorkflowMutationForFullUpdate({
|
||||
sessionId,
|
||||
toolName: 'n8n_update_full_workflow',
|
||||
userIntent,
|
||||
operations: [], // Full update doesn't use diff operations
|
||||
workflowBefore,
|
||||
workflowAfter: workflow,
|
||||
mutationSuccess: true,
|
||||
durationMs: Date.now() - startTime,
|
||||
}).catch(err => {
|
||||
logger.warn('Failed to track mutation telemetry:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: workflow,
|
||||
message: `Workflow "${workflow.name}" updated successfully`
|
||||
};
|
||||
} catch (error) {
|
||||
// Track failed mutation
|
||||
if (workflowBefore) {
|
||||
trackWorkflowMutationForFullUpdate({
|
||||
sessionId,
|
||||
toolName: 'n8n_update_full_workflow',
|
||||
userIntent,
|
||||
operations: [],
|
||||
workflowBefore,
|
||||
workflowAfter: workflowBefore, // No change since it failed
|
||||
mutationSuccess: false,
|
||||
mutationError: error instanceof Error ? error.message : 'Unknown error',
|
||||
durationMs: Date.now() - startTime,
|
||||
}).catch(err => {
|
||||
logger.warn('Failed to track mutation telemetry for failed operation:', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -724,7 +806,7 @@ export async function handleUpdateWorkflow(args: unknown, context?: InstanceCont
|
||||
details: { errors: error.errors }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (error instanceof N8nApiError) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -733,7 +815,7 @@ export async function handleUpdateWorkflow(args: unknown, context?: InstanceCont
|
||||
details: error.details as Record<string, unknown> | undefined
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
@@ -741,6 +823,19 @@ export async function handleUpdateWorkflow(args: unknown, context?: InstanceCont
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track workflow mutation for telemetry (full workflow updates)
|
||||
*/
|
||||
async function trackWorkflowMutationForFullUpdate(data: any): Promise<void> {
|
||||
try {
|
||||
const { telemetry } = await import('../telemetry/telemetry-manager.js');
|
||||
await telemetry.trackWorkflowMutation(data);
|
||||
} catch (error) {
|
||||
// Silently fail - telemetry should never break core functionality
|
||||
logger.debug('Telemetry tracking failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
@@ -995,7 +1090,7 @@ export async function handleAutofixWorkflow(
|
||||
|
||||
// Generate fixes using WorkflowAutoFixer
|
||||
const autoFixer = new WorkflowAutoFixer(repository);
|
||||
const fixResult = autoFixer.generateFixes(
|
||||
const fixResult = await autoFixer.generateFixes(
|
||||
workflow,
|
||||
validationResult,
|
||||
allFormatIssues,
|
||||
@@ -1045,8 +1140,10 @@ export async function handleAutofixWorkflow(
|
||||
const updateResult = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: workflow.id,
|
||||
operations: fixResult.operations
|
||||
operations: fixResult.operations,
|
||||
createBackup: true // Ensure backup is created with autofix metadata
|
||||
},
|
||||
repository,
|
||||
context
|
||||
);
|
||||
|
||||
@@ -1518,7 +1615,6 @@ export async function handleListAvailableTools(context?: InstanceContext): Promi
|
||||
maxRetries: config.maxRetries
|
||||
} : null,
|
||||
limitations: [
|
||||
'Cannot activate/deactivate workflows via API',
|
||||
'Cannot execute workflows directly (must use webhooks)',
|
||||
'Cannot stop running executions',
|
||||
'Tags and credentials have limited API support'
|
||||
@@ -1962,3 +2058,191 @@ export async function handleDiagnostic(request: any, context?: InstanceContext):
|
||||
data: diagnostic
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleWorkflowVersions(
|
||||
args: unknown,
|
||||
repository: NodeRepository,
|
||||
context?: InstanceContext
|
||||
): Promise<McpToolResponse> {
|
||||
try {
|
||||
const input = workflowVersionsSchema.parse(args);
|
||||
const client = context ? getN8nApiClient(context) : null;
|
||||
const versioningService = new WorkflowVersioningService(repository, client || undefined);
|
||||
|
||||
switch (input.mode) {
|
||||
case 'list': {
|
||||
if (!input.workflowId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'workflowId is required for list mode'
|
||||
};
|
||||
}
|
||||
|
||||
const versions = await versioningService.getVersionHistory(input.workflowId, input.limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
workflowId: input.workflowId,
|
||||
versions,
|
||||
count: versions.length,
|
||||
message: `Found ${versions.length} version(s) for workflow ${input.workflowId}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'get': {
|
||||
if (!input.versionId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'versionId is required for get mode'
|
||||
};
|
||||
}
|
||||
|
||||
const version = await versioningService.getVersion(input.versionId);
|
||||
|
||||
if (!version) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Version ${input.versionId} not found`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: version
|
||||
};
|
||||
}
|
||||
|
||||
case 'rollback': {
|
||||
if (!input.workflowId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'workflowId is required for rollback mode'
|
||||
};
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'n8n API not configured. Cannot perform rollback without API access.'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await versioningService.restoreVersion(
|
||||
input.workflowId,
|
||||
input.versionId,
|
||||
input.validateBefore
|
||||
);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
data: result.success ? result : undefined,
|
||||
error: result.success ? undefined : result.message,
|
||||
details: result.success ? undefined : {
|
||||
validationErrors: result.validationErrors
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
if (input.deleteAll) {
|
||||
if (!input.workflowId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'workflowId is required for deleteAll mode'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await versioningService.deleteAllVersions(input.workflowId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
workflowId: input.workflowId,
|
||||
deleted: result.deleted,
|
||||
message: result.message
|
||||
}
|
||||
};
|
||||
} else {
|
||||
if (!input.versionId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'versionId is required for single version delete'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await versioningService.deleteVersion(input.versionId);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
data: result.success ? { message: result.message } : undefined,
|
||||
error: result.success ? undefined : result.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case 'prune': {
|
||||
if (!input.workflowId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'workflowId is required for prune mode'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await versioningService.pruneVersions(
|
||||
input.workflowId,
|
||||
input.maxVersions || 10
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
workflowId: input.workflowId,
|
||||
pruned: result.pruned,
|
||||
remaining: result.remaining,
|
||||
message: `Pruned ${result.pruned} old version(s), ${result.remaining} version(s) remaining`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'truncate': {
|
||||
if (!input.confirmTruncate) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'confirmTruncate must be true to truncate all versions. This action cannot be undone.'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await versioningService.truncateAllVersions(true);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
deleted: result.deleted,
|
||||
message: result.message
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown mode: ${input.mode}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid input',
|
||||
details: { errors: error.errors }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,25 @@ import { getN8nApiClient } from './handlers-n8n-manager';
|
||||
import { N8nApiError, getUserFriendlyErrorMessage } from '../utils/n8n-errors';
|
||||
import { logger } from '../utils/logger';
|
||||
import { InstanceContext } from '../types/instance-context';
|
||||
import { validateWorkflowStructure } from '../services/n8n-validation';
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { WorkflowVersioningService } from '../services/workflow-versioning-service';
|
||||
import { WorkflowValidator } from '../services/workflow-validator';
|
||||
import { EnhancedConfigValidator } from '../services/enhanced-config-validator';
|
||||
|
||||
// Cached validator instance to avoid recreating on every mutation
|
||||
let cachedValidator: WorkflowValidator | null = null;
|
||||
|
||||
/**
|
||||
* Get or create cached workflow validator instance
|
||||
* Reuses the same validator to avoid redundant NodeSimilarityService initialization
|
||||
*/
|
||||
function getValidator(repository: NodeRepository): WorkflowValidator {
|
||||
if (!cachedValidator) {
|
||||
cachedValidator = new WorkflowValidator(repository, EnhancedConfigValidator);
|
||||
}
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
// Zod schema for the diff request
|
||||
const workflowDiffSchema = z.object({
|
||||
@@ -47,23 +66,35 @@ const workflowDiffSchema = z.object({
|
||||
})),
|
||||
validateOnly: z.boolean().optional(),
|
||||
continueOnError: z.boolean().optional(),
|
||||
createBackup: z.boolean().optional(),
|
||||
intent: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function handleUpdatePartialWorkflow(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
export async function handleUpdatePartialWorkflow(
|
||||
args: unknown,
|
||||
repository: NodeRepository,
|
||||
context?: InstanceContext
|
||||
): Promise<McpToolResponse> {
|
||||
const startTime = Date.now();
|
||||
const sessionId = `mutation_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
let workflowBefore: any = null;
|
||||
let validationBefore: any = null;
|
||||
let validationAfter: any = null;
|
||||
|
||||
try {
|
||||
// Debug logging (only in debug mode)
|
||||
if (process.env.DEBUG_MCP === 'true') {
|
||||
logger.debug('Workflow diff request received', {
|
||||
argsType: typeof args,
|
||||
hasWorkflowId: args && typeof args === 'object' && 'workflowId' in args,
|
||||
operationCount: args && typeof args === 'object' && 'operations' in args ?
|
||||
operationCount: args && typeof args === 'object' && 'operations' in args ?
|
||||
(args as any).operations?.length : 0
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Validate input
|
||||
const input = workflowDiffSchema.parse(args);
|
||||
|
||||
|
||||
// Get API client
|
||||
const client = getN8nApiClient(context);
|
||||
if (!client) {
|
||||
@@ -72,11 +103,31 @@ export async function handleUpdatePartialWorkflow(args: unknown, context?: Insta
|
||||
error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Fetch current workflow
|
||||
let workflow;
|
||||
try {
|
||||
workflow = await client.getWorkflow(input.id);
|
||||
// Store original workflow for telemetry
|
||||
workflowBefore = JSON.parse(JSON.stringify(workflow));
|
||||
|
||||
// Validate workflow BEFORE mutation (for telemetry)
|
||||
try {
|
||||
const validator = getValidator(repository);
|
||||
validationBefore = await validator.validateWorkflow(workflowBefore, {
|
||||
validateNodes: true,
|
||||
validateConnections: true,
|
||||
validateExpressions: true,
|
||||
profile: 'runtime'
|
||||
});
|
||||
} catch (validationError) {
|
||||
logger.debug('Pre-mutation validation failed (non-blocking):', validationError);
|
||||
// Don't block mutation on validation errors
|
||||
validationBefore = {
|
||||
valid: false,
|
||||
errors: [{ type: 'validation_error', message: 'Validation failed' }]
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof N8nApiError) {
|
||||
return {
|
||||
@@ -87,7 +138,31 @@ export async function handleUpdatePartialWorkflow(args: unknown, context?: Insta
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
// Create backup before modifying workflow (default: true)
|
||||
if (input.createBackup !== false && !input.validateOnly) {
|
||||
try {
|
||||
const versioningService = new WorkflowVersioningService(repository, client);
|
||||
const backupResult = await versioningService.createBackup(input.id, workflow, {
|
||||
trigger: 'partial_update',
|
||||
operations: input.operations
|
||||
});
|
||||
|
||||
logger.info('Workflow backup created', {
|
||||
workflowId: input.id,
|
||||
versionId: backupResult.versionId,
|
||||
versionNumber: backupResult.versionNumber,
|
||||
pruned: backupResult.pruned
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.warn('Failed to create workflow backup', {
|
||||
workflowId: input.id,
|
||||
error: error.message
|
||||
});
|
||||
// Continue with update even if backup fails (non-blocking)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply diff operations
|
||||
const diffEngine = new WorkflowDiffEngine();
|
||||
const diffRequest = input as WorkflowDiffRequest;
|
||||
@@ -106,6 +181,7 @@ export async function handleUpdatePartialWorkflow(args: unknown, context?: Insta
|
||||
error: 'Failed to apply diff operations',
|
||||
details: {
|
||||
errors: diffResult.errors,
|
||||
warnings: diffResult.warnings,
|
||||
operationsApplied: diffResult.operationsApplied,
|
||||
applied: diffResult.applied,
|
||||
failed: diffResult.failed
|
||||
@@ -122,28 +198,204 @@ export async function handleUpdatePartialWorkflow(args: unknown, context?: Insta
|
||||
data: {
|
||||
valid: true,
|
||||
operationsToApply: input.operations.length
|
||||
},
|
||||
details: {
|
||||
warnings: diffResult.warnings
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Validate final workflow structure after applying all operations
|
||||
// This prevents creating workflows that pass operation-level validation
|
||||
// but fail workflow-level validation (e.g., UI can't render them)
|
||||
//
|
||||
// Validation can be skipped for specific integration tests that need to test
|
||||
// n8n API behavior with edge case workflows by setting SKIP_WORKFLOW_VALIDATION=true
|
||||
if (diffResult.workflow) {
|
||||
const structureErrors = validateWorkflowStructure(diffResult.workflow);
|
||||
if (structureErrors.length > 0) {
|
||||
const skipValidation = process.env.SKIP_WORKFLOW_VALIDATION === 'true';
|
||||
|
||||
logger.warn('Workflow structure validation failed after applying diff operations', {
|
||||
workflowId: input.id,
|
||||
errors: structureErrors,
|
||||
blocking: !skipValidation
|
||||
});
|
||||
|
||||
// Analyze error types to provide targeted recovery guidance
|
||||
const errorTypes = new Set<string>();
|
||||
structureErrors.forEach(err => {
|
||||
if (err.includes('operator') || err.includes('singleValue')) errorTypes.add('operator_issues');
|
||||
if (err.includes('connection') || err.includes('referenced')) errorTypes.add('connection_issues');
|
||||
if (err.includes('Missing') || err.includes('missing')) errorTypes.add('missing_metadata');
|
||||
if (err.includes('branch') || err.includes('output')) errorTypes.add('branch_mismatch');
|
||||
});
|
||||
|
||||
// Build recovery guidance based on error types
|
||||
const recoverySteps = [];
|
||||
if (errorTypes.has('operator_issues')) {
|
||||
recoverySteps.push('Operator structure issue detected. Use validate_node_operation to check specific nodes.');
|
||||
recoverySteps.push('Binary operators (equals, contains, greaterThan, etc.) must NOT have singleValue:true');
|
||||
recoverySteps.push('Unary operators (isEmpty, isNotEmpty, true, false) REQUIRE singleValue:true');
|
||||
}
|
||||
if (errorTypes.has('connection_issues')) {
|
||||
recoverySteps.push('Connection validation failed. Check all node connections reference existing nodes.');
|
||||
recoverySteps.push('Use cleanStaleConnections operation to remove connections to non-existent nodes.');
|
||||
}
|
||||
if (errorTypes.has('missing_metadata')) {
|
||||
recoverySteps.push('Missing metadata detected. Ensure filter-based nodes (IF v2.2+, Switch v3.2+) have complete conditions.options.');
|
||||
recoverySteps.push('Required options: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}');
|
||||
}
|
||||
if (errorTypes.has('branch_mismatch')) {
|
||||
recoverySteps.push('Branch count mismatch. Ensure Switch nodes have outputs for all rules (e.g., 3 rules = 3 output branches).');
|
||||
}
|
||||
|
||||
// Add generic recovery steps if no specific guidance
|
||||
if (recoverySteps.length === 0) {
|
||||
recoverySteps.push('Review the validation errors listed above');
|
||||
recoverySteps.push('Fix issues using updateNode or cleanStaleConnections operations');
|
||||
recoverySteps.push('Run validate_workflow again to verify fixes');
|
||||
}
|
||||
|
||||
const errorMessage = structureErrors.length === 1
|
||||
? `Workflow validation failed: ${structureErrors[0]}`
|
||||
: `Workflow validation failed with ${structureErrors.length} structural issues`;
|
||||
|
||||
// If validation is not skipped, return error and block the save
|
||||
if (!skipValidation) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
details: {
|
||||
errors: structureErrors,
|
||||
errorCount: structureErrors.length,
|
||||
operationsApplied: diffResult.operationsApplied,
|
||||
applied: diffResult.applied,
|
||||
recoveryGuidance: recoverySteps,
|
||||
note: 'Operations were applied but created an invalid workflow structure. The workflow was NOT saved to n8n to prevent UI rendering errors.',
|
||||
autoSanitizationNote: 'Auto-sanitization runs on all nodes during updates to fix operator structures and add missing metadata. However, it cannot fix all issues (e.g., broken connections, branch mismatches). Use the recovery guidance above to resolve remaining issues.'
|
||||
}
|
||||
};
|
||||
}
|
||||
// Validation skipped: log warning but continue (for specific integration tests)
|
||||
logger.info('Workflow validation skipped (SKIP_WORKFLOW_VALIDATION=true): Allowing workflow with validation warnings to proceed', {
|
||||
workflowId: input.id,
|
||||
warningCount: structureErrors.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update workflow via API
|
||||
try {
|
||||
const updatedWorkflow = await client.updateWorkflow(input.id, diffResult.workflow!);
|
||||
|
||||
|
||||
// Handle activation/deactivation if requested
|
||||
let finalWorkflow = updatedWorkflow;
|
||||
let activationMessage = '';
|
||||
|
||||
// Validate workflow AFTER mutation (for telemetry)
|
||||
try {
|
||||
const validator = getValidator(repository);
|
||||
validationAfter = await validator.validateWorkflow(finalWorkflow, {
|
||||
validateNodes: true,
|
||||
validateConnections: true,
|
||||
validateExpressions: true,
|
||||
profile: 'runtime'
|
||||
});
|
||||
} catch (validationError) {
|
||||
logger.debug('Post-mutation validation failed (non-blocking):', validationError);
|
||||
// Don't block on validation errors
|
||||
validationAfter = {
|
||||
valid: false,
|
||||
errors: [{ type: 'validation_error', message: 'Validation failed' }]
|
||||
};
|
||||
}
|
||||
|
||||
if (diffResult.shouldActivate) {
|
||||
try {
|
||||
finalWorkflow = await client.activateWorkflow(input.id);
|
||||
activationMessage = ' Workflow activated.';
|
||||
} catch (activationError) {
|
||||
logger.error('Failed to activate workflow after update', activationError);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Workflow updated successfully but activation failed',
|
||||
details: {
|
||||
workflowUpdated: true,
|
||||
activationError: activationError instanceof Error ? activationError.message : 'Unknown error'
|
||||
}
|
||||
};
|
||||
}
|
||||
} else if (diffResult.shouldDeactivate) {
|
||||
try {
|
||||
finalWorkflow = await client.deactivateWorkflow(input.id);
|
||||
activationMessage = ' Workflow deactivated.';
|
||||
} catch (deactivationError) {
|
||||
logger.error('Failed to deactivate workflow after update', deactivationError);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Workflow updated successfully but deactivation failed',
|
||||
details: {
|
||||
workflowUpdated: true,
|
||||
deactivationError: deactivationError instanceof Error ? deactivationError.message : 'Unknown error'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Track successful mutation
|
||||
if (workflowBefore && !input.validateOnly) {
|
||||
trackWorkflowMutation({
|
||||
sessionId,
|
||||
toolName: 'n8n_update_partial_workflow',
|
||||
userIntent: input.intent || 'Partial workflow update',
|
||||
operations: input.operations,
|
||||
workflowBefore,
|
||||
workflowAfter: finalWorkflow,
|
||||
validationBefore,
|
||||
validationAfter,
|
||||
mutationSuccess: true,
|
||||
durationMs: Date.now() - startTime,
|
||||
}).catch(err => {
|
||||
logger.debug('Failed to track mutation telemetry:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedWorkflow,
|
||||
message: `Workflow "${updatedWorkflow.name}" updated successfully. Applied ${diffResult.operationsApplied} operations.`,
|
||||
data: finalWorkflow,
|
||||
message: `Workflow "${finalWorkflow.name}" updated successfully. Applied ${diffResult.operationsApplied} operations.${activationMessage}`,
|
||||
details: {
|
||||
operationsApplied: diffResult.operationsApplied,
|
||||
workflowId: updatedWorkflow.id,
|
||||
workflowName: updatedWorkflow.name,
|
||||
workflowId: finalWorkflow.id,
|
||||
workflowName: finalWorkflow.name,
|
||||
active: finalWorkflow.active,
|
||||
applied: diffResult.applied,
|
||||
failed: diffResult.failed,
|
||||
errors: diffResult.errors
|
||||
errors: diffResult.errors,
|
||||
warnings: diffResult.warnings
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Track failed mutation
|
||||
if (workflowBefore && !input.validateOnly) {
|
||||
trackWorkflowMutation({
|
||||
sessionId,
|
||||
toolName: 'n8n_update_partial_workflow',
|
||||
userIntent: input.intent || 'Partial workflow update',
|
||||
operations: input.operations,
|
||||
workflowBefore,
|
||||
workflowAfter: workflowBefore, // No change since it failed
|
||||
validationBefore,
|
||||
validationAfter: validationBefore, // Same as before since mutation failed
|
||||
mutationSuccess: false,
|
||||
mutationError: error instanceof Error ? error.message : 'Unknown error',
|
||||
durationMs: Date.now() - startTime,
|
||||
}).catch(err => {
|
||||
logger.warn('Failed to track mutation telemetry for failed operation:', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof N8nApiError) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -162,7 +414,7 @@ export async function handleUpdatePartialWorkflow(args: unknown, context?: Insta
|
||||
details: { errors: error.errors }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
logger.error('Failed to update partial workflow', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -171,3 +423,90 @@ export async function handleUpdatePartialWorkflow(args: unknown, context?: Insta
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer intent from operations when not explicitly provided
|
||||
*/
|
||||
function inferIntentFromOperations(operations: any[]): string {
|
||||
if (!operations || operations.length === 0) {
|
||||
return 'Partial workflow update';
|
||||
}
|
||||
|
||||
const opTypes = operations.map((op) => op.type);
|
||||
const opCount = operations.length;
|
||||
|
||||
// Single operation - be specific
|
||||
if (opCount === 1) {
|
||||
const op = operations[0];
|
||||
switch (op.type) {
|
||||
case 'addNode':
|
||||
return `Add ${op.node?.type || 'node'}`;
|
||||
case 'removeNode':
|
||||
return `Remove node ${op.nodeName || op.nodeId || ''}`.trim();
|
||||
case 'updateNode':
|
||||
return `Update node ${op.nodeName || op.nodeId || ''}`.trim();
|
||||
case 'addConnection':
|
||||
return `Connect ${op.source || 'node'} to ${op.target || 'node'}`;
|
||||
case 'removeConnection':
|
||||
return `Disconnect ${op.source || 'node'} from ${op.target || 'node'}`;
|
||||
case 'rewireConnection':
|
||||
return `Rewire ${op.source || 'node'} from ${op.from || ''} to ${op.to || ''}`.trim();
|
||||
case 'updateName':
|
||||
return `Rename workflow to "${op.name || ''}"`;
|
||||
case 'activateWorkflow':
|
||||
return 'Activate workflow';
|
||||
case 'deactivateWorkflow':
|
||||
return 'Deactivate workflow';
|
||||
default:
|
||||
return `Workflow ${op.type}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple operations - summarize pattern
|
||||
const typeSet = new Set(opTypes);
|
||||
const summary: string[] = [];
|
||||
|
||||
if (typeSet.has('addNode')) {
|
||||
const count = opTypes.filter((t) => t === 'addNode').length;
|
||||
summary.push(`add ${count} node${count > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (typeSet.has('removeNode')) {
|
||||
const count = opTypes.filter((t) => t === 'removeNode').length;
|
||||
summary.push(`remove ${count} node${count > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (typeSet.has('updateNode')) {
|
||||
const count = opTypes.filter((t) => t === 'updateNode').length;
|
||||
summary.push(`update ${count} node${count > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (typeSet.has('addConnection') || typeSet.has('rewireConnection')) {
|
||||
summary.push('modify connections');
|
||||
}
|
||||
if (typeSet.has('updateName') || typeSet.has('updateSettings')) {
|
||||
summary.push('update metadata');
|
||||
}
|
||||
|
||||
return summary.length > 0
|
||||
? `Workflow update: ${summary.join(', ')}`
|
||||
: `Workflow update: ${opCount} operations`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track workflow mutation for telemetry
|
||||
*/
|
||||
async function trackWorkflowMutation(data: any): Promise<void> {
|
||||
try {
|
||||
// Enhance intent if it's missing or generic
|
||||
if (
|
||||
!data.userIntent ||
|
||||
data.userIntent === 'Partial workflow update' ||
|
||||
data.userIntent.length < 10
|
||||
) {
|
||||
data.userIntent = inferIntentFromOperations(data.operations);
|
||||
}
|
||||
|
||||
const { telemetry } = await import('../telemetry/telemetry-manager.js');
|
||||
await telemetry.trackWorkflowMutation(data);
|
||||
} catch (error) {
|
||||
logger.debug('Telemetry tracking failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { TaskTemplates } from '../services/task-templates';
|
||||
import { ConfigValidator } from '../services/config-validator';
|
||||
import { EnhancedConfigValidator, ValidationMode, ValidationProfile } from '../services/enhanced-config-validator';
|
||||
import { PropertyDependencies } from '../services/property-dependencies';
|
||||
import { TypeStructureService } from '../services/type-structure-service';
|
||||
import { SimpleCache } from '../utils/simple-cache';
|
||||
import { TemplateService } from '../templates/template-service';
|
||||
import { WorkflowValidator } from '../services/workflow-validator';
|
||||
@@ -58,6 +59,67 @@ interface NodeRow {
|
||||
credentials_required?: string;
|
||||
}
|
||||
|
||||
interface VersionSummary {
|
||||
currentVersion: string;
|
||||
totalVersions: number;
|
||||
hasVersionHistory: boolean;
|
||||
}
|
||||
|
||||
interface NodeMinimalInfo {
|
||||
nodeType: string;
|
||||
workflowNodeType: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
package: string;
|
||||
isAITool: boolean;
|
||||
isTrigger: boolean;
|
||||
isWebhook: boolean;
|
||||
}
|
||||
|
||||
interface NodeStandardInfo {
|
||||
nodeType: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
requiredProperties: any[];
|
||||
commonProperties: any[];
|
||||
operations?: any[];
|
||||
credentials?: any;
|
||||
examples?: any[];
|
||||
versionInfo: VersionSummary;
|
||||
}
|
||||
|
||||
interface NodeFullInfo {
|
||||
nodeType: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
properties: any[];
|
||||
operations?: any[];
|
||||
credentials?: any;
|
||||
documentation?: string;
|
||||
versionInfo: VersionSummary;
|
||||
}
|
||||
|
||||
interface VersionHistoryInfo {
|
||||
nodeType: string;
|
||||
versions: any[];
|
||||
latestVersion: string;
|
||||
hasBreakingChanges: boolean;
|
||||
}
|
||||
|
||||
interface VersionComparisonInfo {
|
||||
nodeType: string;
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
changes: any[];
|
||||
breakingChanges?: any[];
|
||||
migrations?: any[];
|
||||
}
|
||||
|
||||
type NodeInfoResponse = NodeMinimalInfo | NodeStandardInfo | NodeFullInfo | VersionHistoryInfo | VersionComparisonInfo;
|
||||
|
||||
export class N8NDocumentationMCPServer {
|
||||
private server: Server;
|
||||
private db: DatabaseAdapter | null = null;
|
||||
@@ -70,6 +132,7 @@ export class N8NDocumentationMCPServer {
|
||||
private previousTool: string | null = null;
|
||||
private previousToolTimestamp: number = Date.now();
|
||||
private earlyLogger: EarlyErrorLogger | null = null;
|
||||
private disabledToolsCache: Set<string> | null = null;
|
||||
|
||||
constructor(instanceContext?: InstanceContext, earlyLogger?: EarlyErrorLogger) {
|
||||
this.instanceContext = instanceContext;
|
||||
@@ -128,7 +191,25 @@ export class N8NDocumentationMCPServer {
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'n8n-documentation-mcp',
|
||||
version: '1.0.0',
|
||||
version: PROJECT_VERSION,
|
||||
icons: [
|
||||
{
|
||||
src: "https://www.n8n-mcp.com/logo.png",
|
||||
mimeType: "image/png",
|
||||
sizes: ["192x192"]
|
||||
},
|
||||
{
|
||||
src: "https://www.n8n-mcp.com/logo-128.png",
|
||||
mimeType: "image/png",
|
||||
sizes: ["128x128"]
|
||||
},
|
||||
{
|
||||
src: "https://www.n8n-mcp.com/logo-48.png",
|
||||
mimeType: "image/png",
|
||||
sizes: ["48x48"]
|
||||
}
|
||||
],
|
||||
websiteUrl: "https://n8n-mcp.com"
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
@@ -278,19 +359,24 @@ export class N8NDocumentationMCPServer {
|
||||
throw new Error('Database is empty. Run "npm run rebuild" to populate node data.');
|
||||
}
|
||||
|
||||
// Check if FTS5 table exists
|
||||
const ftsExists = this.db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='nodes_fts'
|
||||
`).get();
|
||||
// Check if FTS5 table exists (wrap in try-catch for sql.js compatibility)
|
||||
try {
|
||||
const ftsExists = this.db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='nodes_fts'
|
||||
`).get();
|
||||
|
||||
if (!ftsExists) {
|
||||
logger.warn('FTS5 table missing - search performance will be degraded. Please run: npm run rebuild');
|
||||
} else {
|
||||
const ftsCount = this.db.prepare('SELECT COUNT(*) as count FROM nodes_fts').get() as { count: number };
|
||||
if (ftsCount.count === 0) {
|
||||
logger.warn('FTS5 index is empty - search will not work properly. Please run: npm run rebuild');
|
||||
if (!ftsExists) {
|
||||
logger.warn('FTS5 table missing - search performance will be degraded. Please run: npm run rebuild');
|
||||
} else {
|
||||
const ftsCount = this.db.prepare('SELECT COUNT(*) as count FROM nodes_fts').get() as { count: number };
|
||||
if (ftsCount.count === 0) {
|
||||
logger.warn('FTS5 index is empty - search will not work properly. Please run: npm run rebuild');
|
||||
}
|
||||
}
|
||||
} catch (ftsError) {
|
||||
// FTS5 not supported (e.g., sql.js fallback) - this is OK, just warn
|
||||
logger.warn('FTS5 not available - using fallback search. For better performance, ensure better-sqlite3 is properly installed.');
|
||||
}
|
||||
|
||||
logger.info(`Database health check passed: ${nodeCount.count} nodes loaded`);
|
||||
@@ -300,6 +386,52 @@ export class N8NDocumentationMCPServer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and cache disabled tools from DISABLED_TOOLS environment variable.
|
||||
* Returns a Set of tool names that should be filtered from registration.
|
||||
*
|
||||
* Cached after first call since environment variables don't change at runtime.
|
||||
* Includes safety limits: max 10KB env var length, max 200 tools.
|
||||
*
|
||||
* @returns Set of disabled tool names
|
||||
*/
|
||||
private getDisabledTools(): Set<string> {
|
||||
// Return cached value if available
|
||||
if (this.disabledToolsCache !== null) {
|
||||
return this.disabledToolsCache;
|
||||
}
|
||||
|
||||
let disabledToolsEnv = process.env.DISABLED_TOOLS || '';
|
||||
if (!disabledToolsEnv) {
|
||||
this.disabledToolsCache = new Set();
|
||||
return this.disabledToolsCache;
|
||||
}
|
||||
|
||||
// Safety limit: prevent abuse with very long environment variables
|
||||
if (disabledToolsEnv.length > 10000) {
|
||||
logger.warn(`DISABLED_TOOLS environment variable too long (${disabledToolsEnv.length} chars), truncating to 10000`);
|
||||
disabledToolsEnv = disabledToolsEnv.substring(0, 10000);
|
||||
}
|
||||
|
||||
let tools = disabledToolsEnv
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Safety limit: prevent abuse with too many tools
|
||||
if (tools.length > 200) {
|
||||
logger.warn(`DISABLED_TOOLS contains ${tools.length} tools, limiting to first 200`);
|
||||
tools = tools.slice(0, 200);
|
||||
}
|
||||
|
||||
if (tools.length > 0) {
|
||||
logger.info(`Disabled tools configured: ${tools.join(', ')}`);
|
||||
}
|
||||
|
||||
this.disabledToolsCache = new Set(tools);
|
||||
return this.disabledToolsCache;
|
||||
}
|
||||
|
||||
private setupHandlers(): void {
|
||||
// Handle initialization
|
||||
this.server.setRequestHandler(InitializeRequestSchema, async (request) => {
|
||||
@@ -353,8 +485,16 @@ export class N8NDocumentationMCPServer {
|
||||
|
||||
// Handle tool listing
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async (request) => {
|
||||
// Get disabled tools from environment variable
|
||||
const disabledTools = this.getDisabledTools();
|
||||
|
||||
// Filter documentation tools based on disabled list
|
||||
const enabledDocTools = n8nDocumentationToolsFinal.filter(
|
||||
tool => !disabledTools.has(tool.name)
|
||||
);
|
||||
|
||||
// Combine documentation tools with management tools if API is configured
|
||||
let tools = [...n8nDocumentationToolsFinal];
|
||||
let tools = [...enabledDocTools];
|
||||
|
||||
// Check if n8n API tools should be available
|
||||
// 1. Environment variables (backward compatibility)
|
||||
@@ -367,19 +507,31 @@ export class N8NDocumentationMCPServer {
|
||||
const shouldIncludeManagementTools = hasEnvConfig || hasInstanceConfig || isMultiTenantEnabled;
|
||||
|
||||
if (shouldIncludeManagementTools) {
|
||||
tools.push(...n8nManagementTools);
|
||||
logger.debug(`Tool listing: ${tools.length} tools available (${n8nDocumentationToolsFinal.length} documentation + ${n8nManagementTools.length} management)`, {
|
||||
// Filter management tools based on disabled list
|
||||
const enabledMgmtTools = n8nManagementTools.filter(
|
||||
tool => !disabledTools.has(tool.name)
|
||||
);
|
||||
tools.push(...enabledMgmtTools);
|
||||
logger.debug(`Tool listing: ${tools.length} tools available (${enabledDocTools.length} documentation + ${enabledMgmtTools.length} management)`, {
|
||||
hasEnvConfig,
|
||||
hasInstanceConfig,
|
||||
isMultiTenantEnabled
|
||||
isMultiTenantEnabled,
|
||||
disabledToolsCount: disabledTools.size
|
||||
});
|
||||
} else {
|
||||
logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`, {
|
||||
hasEnvConfig,
|
||||
hasInstanceConfig,
|
||||
isMultiTenantEnabled
|
||||
isMultiTenantEnabled,
|
||||
disabledToolsCount: disabledTools.size
|
||||
});
|
||||
}
|
||||
|
||||
// Log filtered tools count if any tools are disabled
|
||||
if (disabledTools.size > 0) {
|
||||
const totalAvailableTools = n8nDocumentationToolsFinal.length + (shouldIncludeManagementTools ? n8nManagementTools.length : 0);
|
||||
logger.debug(`Filtered ${disabledTools.size} disabled tools, ${tools.length}/${totalAvailableTools} tools available`);
|
||||
}
|
||||
|
||||
// Check if client is n8n (from initialization)
|
||||
const clientInfo = this.clientInfo;
|
||||
@@ -420,7 +572,23 @@ export class N8NDocumentationMCPServer {
|
||||
configType: args && args.config ? typeof args.config : 'N/A',
|
||||
rawRequest: JSON.stringify(request.params)
|
||||
});
|
||||
|
||||
|
||||
// Check if tool is disabled via DISABLED_TOOLS environment variable
|
||||
const disabledTools = this.getDisabledTools();
|
||||
if (disabledTools.has(name)) {
|
||||
logger.warn(`Attempted to call disabled tool: ${name}`);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
error: 'TOOL_DISABLED',
|
||||
message: `Tool '${name}' is not available in this deployment. It has been disabled via DISABLED_TOOLS environment variable.`,
|
||||
tool: name
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Workaround for n8n's nested output bug
|
||||
// Check if args contains nested 'output' structure from n8n's memory corruption
|
||||
let processedArgs = args;
|
||||
@@ -822,19 +990,27 @@ export class N8NDocumentationMCPServer {
|
||||
async executeTool(name: string, args: any): Promise<any> {
|
||||
// Ensure args is an object and validate it
|
||||
args = args || {};
|
||||
|
||||
|
||||
// Defense in depth: This should never be reached since CallToolRequestSchema
|
||||
// handler already checks disabled tools (line 514-528), but we guard here
|
||||
// in case of future refactoring or direct executeTool() calls
|
||||
const disabledTools = this.getDisabledTools();
|
||||
if (disabledTools.has(name)) {
|
||||
throw new Error(`Tool '${name}' is disabled via DISABLED_TOOLS environment variable`);
|
||||
}
|
||||
|
||||
// Log the tool call for debugging n8n issues
|
||||
logger.info(`Tool execution: ${name}`, {
|
||||
logger.info(`Tool execution: ${name}`, {
|
||||
args: typeof args === 'object' ? JSON.stringify(args) : args,
|
||||
argsType: typeof args,
|
||||
argsKeys: typeof args === 'object' ? Object.keys(args) : 'not-object'
|
||||
});
|
||||
|
||||
|
||||
// Validate that args is actually an object
|
||||
if (typeof args !== 'object' || args === null) {
|
||||
throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`);
|
||||
}
|
||||
|
||||
|
||||
switch (name) {
|
||||
case 'tools_documentation':
|
||||
// No required parameters
|
||||
@@ -842,9 +1018,6 @@ export class N8NDocumentationMCPServer {
|
||||
case 'list_nodes':
|
||||
// No required parameters
|
||||
return this.listNodes(args);
|
||||
case 'get_node_info':
|
||||
this.validateToolParams(name, args, ['nodeType']);
|
||||
return this.getNodeInfo(args.nodeType);
|
||||
case 'search_nodes':
|
||||
this.validateToolParams(name, args, ['query']);
|
||||
// Convert limit to number if provided, otherwise use default
|
||||
@@ -859,9 +1032,17 @@ export class N8NDocumentationMCPServer {
|
||||
case 'get_database_statistics':
|
||||
// No required parameters
|
||||
return this.getDatabaseStatistics();
|
||||
case 'get_node_essentials':
|
||||
case 'get_node':
|
||||
this.validateToolParams(name, args, ['nodeType']);
|
||||
return this.getNodeEssentials(args.nodeType, args.includeExamples);
|
||||
return this.getNode(
|
||||
args.nodeType,
|
||||
args.detail,
|
||||
args.mode,
|
||||
args.includeTypeInfo,
|
||||
args.includeExamples,
|
||||
args.fromVersion,
|
||||
args.toVersion
|
||||
);
|
||||
case 'search_node_properties':
|
||||
this.validateToolParams(name, args, ['nodeType', 'query']);
|
||||
const maxResults = args.maxResults !== undefined ? Number(args.maxResults) || 20 : 20;
|
||||
@@ -991,10 +1172,10 @@ export class N8NDocumentationMCPServer {
|
||||
return n8nHandlers.handleGetWorkflowMinimal(args, this.instanceContext);
|
||||
case 'n8n_update_full_workflow':
|
||||
this.validateToolParams(name, args, ['id']);
|
||||
return n8nHandlers.handleUpdateWorkflow(args, this.instanceContext);
|
||||
return n8nHandlers.handleUpdateWorkflow(args, this.repository!, this.instanceContext);
|
||||
case 'n8n_update_partial_workflow':
|
||||
this.validateToolParams(name, args, ['id', 'operations']);
|
||||
return handleUpdatePartialWorkflow(args, this.instanceContext);
|
||||
return handleUpdatePartialWorkflow(args, this.repository!, this.instanceContext);
|
||||
case 'n8n_delete_workflow':
|
||||
this.validateToolParams(name, args, ['id']);
|
||||
return n8nHandlers.handleDeleteWorkflow(args, this.instanceContext);
|
||||
@@ -1032,7 +1213,10 @@ export class N8NDocumentationMCPServer {
|
||||
case 'n8n_diagnostic':
|
||||
// No required parameters
|
||||
return n8nHandlers.handleDiagnostic({ params: { arguments: args } }, this.instanceContext);
|
||||
|
||||
case 'n8n_workflow_versions':
|
||||
this.validateToolParams(name, args, ['mode']);
|
||||
return n8nHandlers.handleWorkflowVersions(args, this.repository!, this.instanceContext);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
@@ -1258,20 +1442,20 @@ export class N8NDocumentationMCPServer {
|
||||
try {
|
||||
// Use FTS5 with ranking
|
||||
const nodes = this.db.prepare(`
|
||||
SELECT
|
||||
SELECT
|
||||
n.*,
|
||||
rank
|
||||
FROM nodes n
|
||||
JOIN nodes_fts ON n.rowid = nodes_fts.rowid
|
||||
WHERE nodes_fts MATCH ?
|
||||
ORDER BY
|
||||
rank,
|
||||
CASE
|
||||
WHEN n.display_name = ? THEN 0
|
||||
WHEN n.display_name LIKE ? THEN 1
|
||||
WHEN n.node_type LIKE ? THEN 2
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(n.display_name) = LOWER(?) THEN 0
|
||||
WHEN LOWER(n.display_name) LIKE LOWER(?) THEN 1
|
||||
WHEN LOWER(n.node_type) LIKE LOWER(?) THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
rank,
|
||||
n.display_name
|
||||
LIMIT ?
|
||||
`).all(ftsQuery, cleanedQuery, `%${cleanedQuery}%`, `%${cleanedQuery}%`, limit) as (NodeRow & { rank: number })[];
|
||||
@@ -2101,6 +2285,393 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified node information retrieval with multiple detail levels and modes.
|
||||
*
|
||||
* @param nodeType - Full node type identifier (e.g., "nodes-base.httpRequest" or "nodes-langchain.agent")
|
||||
* @param detail - Information detail level (minimal, standard, full). Only applies when mode='info'.
|
||||
* - minimal: ~200 tokens, basic metadata only (no version info)
|
||||
* - standard: ~1-2K tokens, essential properties and operations (includes version info, AI-friendly default)
|
||||
* - full: ~3-8K tokens, complete node information with all properties (includes version info)
|
||||
* @param mode - Operation mode determining the type of information returned:
|
||||
* - info: Node configuration details (respects detail level)
|
||||
* - versions: Complete version history with breaking changes summary
|
||||
* - compare: Property-level comparison between two versions (requires fromVersion)
|
||||
* - breaking: Breaking changes only between versions (requires fromVersion)
|
||||
* - migrations: Auto-migratable changes between versions (requires both fromVersion and toVersion)
|
||||
* @param includeTypeInfo - Include type structure metadata for properties (only applies to mode='info').
|
||||
* Adds ~80-120 tokens per property with type category, JS type, and validation rules.
|
||||
* @param includeExamples - Include real-world configuration examples from templates (only applies to mode='info' with detail='standard').
|
||||
* Adds ~200-400 tokens per example.
|
||||
* @param fromVersion - Source version for comparison modes (required for compare, breaking, migrations).
|
||||
* Format: "1.0" or "2.1"
|
||||
* @param toVersion - Target version for comparison modes (optional for compare/breaking, required for migrations).
|
||||
* Defaults to latest version if omitted.
|
||||
* @returns NodeInfoResponse - Union type containing different response structures based on mode and detail parameters
|
||||
*/
|
||||
private async getNode(
|
||||
nodeType: string,
|
||||
detail: string = 'standard',
|
||||
mode: string = 'info',
|
||||
includeTypeInfo?: boolean,
|
||||
includeExamples?: boolean,
|
||||
fromVersion?: string,
|
||||
toVersion?: string
|
||||
): Promise<NodeInfoResponse> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
// Validate parameters
|
||||
const validDetailLevels = ['minimal', 'standard', 'full'];
|
||||
const validModes = ['info', 'versions', 'compare', 'breaking', 'migrations'];
|
||||
|
||||
if (!validDetailLevels.includes(detail)) {
|
||||
throw new Error(`get_node: Invalid detail level "${detail}". Valid options: ${validDetailLevels.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!validModes.includes(mode)) {
|
||||
throw new Error(`get_node: Invalid mode "${mode}". Valid options: ${validModes.join(', ')}`);
|
||||
}
|
||||
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
|
||||
// Version modes - detail level ignored
|
||||
if (mode !== 'info') {
|
||||
return this.handleVersionMode(
|
||||
normalizedType,
|
||||
mode,
|
||||
fromVersion,
|
||||
toVersion
|
||||
);
|
||||
}
|
||||
|
||||
// Info mode - respect detail level
|
||||
return this.handleInfoMode(
|
||||
normalizedType,
|
||||
detail,
|
||||
includeTypeInfo,
|
||||
includeExamples
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle info mode - returns node information at specified detail level
|
||||
*/
|
||||
private async handleInfoMode(
|
||||
nodeType: string,
|
||||
detail: string,
|
||||
includeTypeInfo?: boolean,
|
||||
includeExamples?: boolean
|
||||
): Promise<NodeMinimalInfo | NodeStandardInfo | NodeFullInfo> {
|
||||
switch (detail) {
|
||||
case 'minimal': {
|
||||
// Get basic node metadata only (no version info for minimal mode)
|
||||
let node = this.repository!.getNode(nodeType);
|
||||
|
||||
if (!node) {
|
||||
const alternatives = getNodeTypeAlternatives(nodeType);
|
||||
for (const alt of alternatives) {
|
||||
const found = this.repository!.getNode(alt);
|
||||
if (found) {
|
||||
node = found;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
throw new Error(`Node ${nodeType} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
nodeType: node.nodeType,
|
||||
workflowNodeType: getWorkflowNodeType(node.package ?? 'n8n-nodes-base', node.nodeType),
|
||||
displayName: node.displayName,
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package,
|
||||
isAITool: node.isAITool,
|
||||
isTrigger: node.isTrigger,
|
||||
isWebhook: node.isWebhook
|
||||
};
|
||||
}
|
||||
|
||||
case 'standard': {
|
||||
// Use existing getNodeEssentials logic
|
||||
const essentials = await this.getNodeEssentials(nodeType, includeExamples);
|
||||
const versionSummary = this.getVersionSummary(nodeType);
|
||||
|
||||
// Apply type info enrichment if requested
|
||||
if (includeTypeInfo) {
|
||||
essentials.requiredProperties = this.enrichPropertiesWithTypeInfo(essentials.requiredProperties);
|
||||
essentials.commonProperties = this.enrichPropertiesWithTypeInfo(essentials.commonProperties);
|
||||
}
|
||||
|
||||
return {
|
||||
...essentials,
|
||||
versionInfo: versionSummary
|
||||
};
|
||||
}
|
||||
|
||||
case 'full': {
|
||||
// Use existing getNodeInfo logic
|
||||
const fullInfo = await this.getNodeInfo(nodeType);
|
||||
const versionSummary = this.getVersionSummary(nodeType);
|
||||
|
||||
// Apply type info enrichment if requested
|
||||
if (includeTypeInfo && fullInfo.properties) {
|
||||
fullInfo.properties = this.enrichPropertiesWithTypeInfo(fullInfo.properties);
|
||||
}
|
||||
|
||||
return {
|
||||
...fullInfo,
|
||||
versionInfo: versionSummary
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown detail level: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle version modes - returns version history and comparison data
|
||||
*/
|
||||
private async handleVersionMode(
|
||||
nodeType: string,
|
||||
mode: string,
|
||||
fromVersion?: string,
|
||||
toVersion?: string
|
||||
): Promise<VersionHistoryInfo | VersionComparisonInfo> {
|
||||
switch (mode) {
|
||||
case 'versions':
|
||||
return this.getVersionHistory(nodeType);
|
||||
|
||||
case 'compare':
|
||||
if (!fromVersion) {
|
||||
throw new Error(`get_node: fromVersion is required for compare mode (nodeType: ${nodeType})`);
|
||||
}
|
||||
return this.compareVersions(nodeType, fromVersion, toVersion);
|
||||
|
||||
case 'breaking':
|
||||
if (!fromVersion) {
|
||||
throw new Error(`get_node: fromVersion is required for breaking mode (nodeType: ${nodeType})`);
|
||||
}
|
||||
return this.getBreakingChanges(nodeType, fromVersion, toVersion);
|
||||
|
||||
case 'migrations':
|
||||
if (!fromVersion || !toVersion) {
|
||||
throw new Error(`get_node: Both fromVersion and toVersion are required for migrations mode (nodeType: ${nodeType})`);
|
||||
}
|
||||
return this.getMigrations(nodeType, fromVersion, toVersion);
|
||||
|
||||
default:
|
||||
throw new Error(`get_node: Unknown mode: ${mode} (nodeType: ${nodeType})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version summary (always included in info mode responses)
|
||||
* Cached for 24 hours to improve performance
|
||||
*/
|
||||
private getVersionSummary(nodeType: string): VersionSummary {
|
||||
const cacheKey = `version-summary:${nodeType}`;
|
||||
const cached = this.cache.get(cacheKey) as VersionSummary | null;
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const versions = this.repository!.getNodeVersions(nodeType);
|
||||
const latest = this.repository!.getLatestNodeVersion(nodeType);
|
||||
|
||||
const summary: VersionSummary = {
|
||||
currentVersion: latest?.version || 'unknown',
|
||||
totalVersions: versions.length,
|
||||
hasVersionHistory: versions.length > 0
|
||||
};
|
||||
|
||||
// Cache for 24 hours (86400000 ms)
|
||||
this.cache.set(cacheKey, summary, 86400000);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete version history for a node
|
||||
*/
|
||||
private getVersionHistory(nodeType: string): any {
|
||||
const versions = this.repository!.getNodeVersions(nodeType);
|
||||
|
||||
return {
|
||||
nodeType,
|
||||
totalVersions: versions.length,
|
||||
versions: versions.map(v => ({
|
||||
version: v.version,
|
||||
isCurrent: v.isCurrentMax,
|
||||
minimumN8nVersion: v.minimumN8nVersion,
|
||||
releasedAt: v.releasedAt,
|
||||
hasBreakingChanges: (v.breakingChanges || []).length > 0,
|
||||
breakingChangesCount: (v.breakingChanges || []).length,
|
||||
deprecatedProperties: v.deprecatedProperties || [],
|
||||
addedProperties: v.addedProperties || []
|
||||
})),
|
||||
available: versions.length > 0,
|
||||
message: versions.length === 0 ?
|
||||
'No version history available. Version tracking may not be enabled for this node.' :
|
||||
undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions of a node
|
||||
*/
|
||||
private compareVersions(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion?: string
|
||||
): any {
|
||||
const latest = this.repository!.getLatestNodeVersion(nodeType);
|
||||
const targetVersion = toVersion || latest?.version;
|
||||
|
||||
if (!targetVersion) {
|
||||
throw new Error('No target version available');
|
||||
}
|
||||
|
||||
const changes = this.repository!.getPropertyChanges(
|
||||
nodeType,
|
||||
fromVersion,
|
||||
targetVersion
|
||||
);
|
||||
|
||||
return {
|
||||
nodeType,
|
||||
fromVersion,
|
||||
toVersion: targetVersion,
|
||||
totalChanges: changes.length,
|
||||
breakingChanges: changes.filter(c => c.isBreaking).length,
|
||||
changes: changes.map(c => ({
|
||||
property: c.propertyName,
|
||||
changeType: c.changeType,
|
||||
isBreaking: c.isBreaking,
|
||||
severity: c.severity,
|
||||
oldValue: c.oldValue,
|
||||
newValue: c.newValue,
|
||||
migrationHint: c.migrationHint,
|
||||
autoMigratable: c.autoMigratable
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breaking changes between versions
|
||||
*/
|
||||
private getBreakingChanges(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion?: string
|
||||
): any {
|
||||
const breakingChanges = this.repository!.getBreakingChanges(
|
||||
nodeType,
|
||||
fromVersion,
|
||||
toVersion
|
||||
);
|
||||
|
||||
return {
|
||||
nodeType,
|
||||
fromVersion,
|
||||
toVersion: toVersion || 'latest',
|
||||
totalBreakingChanges: breakingChanges.length,
|
||||
changes: breakingChanges.map(c => ({
|
||||
fromVersion: c.fromVersion,
|
||||
toVersion: c.toVersion,
|
||||
property: c.propertyName,
|
||||
changeType: c.changeType,
|
||||
severity: c.severity,
|
||||
migrationHint: c.migrationHint,
|
||||
oldValue: c.oldValue,
|
||||
newValue: c.newValue
|
||||
})),
|
||||
upgradeSafe: breakingChanges.length === 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auto-migratable changes between versions
|
||||
*/
|
||||
private getMigrations(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion: string
|
||||
): any {
|
||||
const migrations = this.repository!.getAutoMigratableChanges(
|
||||
nodeType,
|
||||
fromVersion,
|
||||
toVersion
|
||||
);
|
||||
|
||||
const allChanges = this.repository!.getPropertyChanges(
|
||||
nodeType,
|
||||
fromVersion,
|
||||
toVersion
|
||||
);
|
||||
|
||||
return {
|
||||
nodeType,
|
||||
fromVersion,
|
||||
toVersion,
|
||||
autoMigratableChanges: migrations.length,
|
||||
totalChanges: allChanges.length,
|
||||
migrations: migrations.map(m => ({
|
||||
property: m.propertyName,
|
||||
changeType: m.changeType,
|
||||
migrationStrategy: m.migrationStrategy,
|
||||
severity: m.severity
|
||||
})),
|
||||
requiresManualMigration: migrations.length < allChanges.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich property with type structure metadata
|
||||
*/
|
||||
private enrichPropertyWithTypeInfo(property: any): any {
|
||||
if (!property || !property.type) return property;
|
||||
|
||||
const structure = TypeStructureService.getStructure(property.type);
|
||||
if (!structure) return property;
|
||||
|
||||
return {
|
||||
...property,
|
||||
typeInfo: {
|
||||
category: structure.type,
|
||||
jsType: structure.jsType,
|
||||
description: structure.description,
|
||||
isComplex: TypeStructureService.isComplexType(property.type),
|
||||
isPrimitive: TypeStructureService.isPrimitiveType(property.type),
|
||||
allowsExpressions: structure.validation?.allowExpressions ?? true,
|
||||
allowsEmpty: structure.validation?.allowEmpty ?? false,
|
||||
...(structure.structure && {
|
||||
structureHints: {
|
||||
hasProperties: !!structure.structure.properties,
|
||||
hasItems: !!structure.structure.items,
|
||||
isFlexible: structure.structure.flexible ?? false,
|
||||
requiredFields: structure.structure.required ?? []
|
||||
}
|
||||
}),
|
||||
...(structure.notes && { notes: structure.notes })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich an array of properties with type structure metadata
|
||||
*/
|
||||
private enrichPropertiesWithTypeInfo(properties: any[]): any[] {
|
||||
if (!properties || !Array.isArray(properties)) return properties;
|
||||
return properties.map((prop: any) => this.enrichPropertyWithTypeInfo(prop));
|
||||
}
|
||||
|
||||
private async searchNodeProperties(nodeType: string, query: string, maxResults: number = 20): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
@@ -48,7 +48,7 @@ An n8n AI Agent workflow typically consists of:
|
||||
- Manages conversation flow
|
||||
- Decides when to use tools
|
||||
- Iterates until task is complete
|
||||
- Supports fallback models (v2.1+)
|
||||
- Supports fallback models for reliability
|
||||
|
||||
3. **Language Model**: The AI brain
|
||||
- OpenAI GPT-4, Claude, Gemini, etc.
|
||||
@@ -441,7 +441,7 @@ For real-time user experience:
|
||||
|
||||
### Pattern 2: Fallback Language Models
|
||||
|
||||
For production reliability (requires AI Agent v2.1+):
|
||||
For production reliability with fallback language models:
|
||||
|
||||
\`\`\`typescript
|
||||
n8n_update_partial_workflow({
|
||||
@@ -724,7 +724,7 @@ n8n_validate_workflow({id: "workflow_id"})
|
||||
'Always validate workflows after making changes',
|
||||
'AI connections require sourceOutput parameter',
|
||||
'Streaming mode has specific constraints',
|
||||
'Some features require specific AI Agent versions (v2.1+ for fallback)'
|
||||
'Fallback models require AI Agent node with fallback support'
|
||||
],
|
||||
relatedTools: [
|
||||
'n8n_create_workflow',
|
||||
|
||||
@@ -11,7 +11,8 @@ export const validateNodeOperationDoc: ToolDocumentation = {
|
||||
tips: [
|
||||
'Profile choices: minimal (editing), runtime (execution), ai-friendly (balanced), strict (deployment)',
|
||||
'Returns fixes you can apply directly',
|
||||
'Operation-aware - knows Slack post needs text'
|
||||
'Operation-aware - knows Slack post needs text',
|
||||
'Validates operator structures for IF and Switch nodes with conditions'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
@@ -71,7 +72,9 @@ export const validateNodeOperationDoc: ToolDocumentation = {
|
||||
'Validate configuration before workflow execution',
|
||||
'Debug why a node isn\'t working as expected',
|
||||
'Generate configuration fixes automatically',
|
||||
'Different validation for editing vs production'
|
||||
'Different validation for editing vs production',
|
||||
'Check IF/Switch operator structures (binary vs unary operators)',
|
||||
'Validate conditions.options metadata for filter-based nodes'
|
||||
],
|
||||
performance: '<100ms for most nodes, <200ms for complex nodes with many conditions',
|
||||
bestPractices: [
|
||||
@@ -85,7 +88,10 @@ export const validateNodeOperationDoc: ToolDocumentation = {
|
||||
pitfalls: [
|
||||
'Must include operation fields for multi-operation nodes',
|
||||
'Fixes are suggestions - review before applying',
|
||||
'Profile affects what\'s validated - minimal skips many checks'
|
||||
'Profile affects what\'s validated - minimal skips many checks',
|
||||
'**Binary vs Unary operators**: Binary operators (equals, contains, greaterThan) must NOT have singleValue:true. Unary operators (isEmpty, isNotEmpty, true, false) REQUIRE singleValue:true',
|
||||
'**IF and Switch nodes with conditions**: Must have complete conditions.options structure: {version: 2, leftValue: "", caseSensitive: true/false, typeValidation: "strict"}',
|
||||
'**Operator type field**: Must be data type (string/number/boolean/dateTime/array/object), NOT operation name (e.g., use type:"string" operation:"equals", not type:"equals")'
|
||||
],
|
||||
relatedTools: ['validate_node_minimal for quick checks', 'get_node_essentials for valid examples', 'validate_workflow for complete workflow validation']
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ export const validateWorkflowDoc: ToolDocumentation = {
|
||||
tips: [
|
||||
'Always validate before n8n_create_workflow to catch errors early',
|
||||
'Use options.profile="minimal" for quick checks during development',
|
||||
'AI tool connections are automatically validated for proper node references'
|
||||
'AI tool connections are automatically validated for proper node references',
|
||||
'Detects operator structure issues (binary vs unary, singleValue requirements)'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
@@ -67,7 +68,9 @@ export const validateWorkflowDoc: ToolDocumentation = {
|
||||
'Use minimal profile during development, strict profile before production',
|
||||
'Pay attention to warnings - they often indicate potential runtime issues',
|
||||
'Validate after any workflow modifications, especially connection changes',
|
||||
'Check statistics to understand workflow complexity'
|
||||
'Check statistics to understand workflow complexity',
|
||||
'**Auto-sanitization runs during create/update**: Operator structures and missing metadata are automatically fixed when workflows are created or updated, but validation helps catch issues before they reach n8n',
|
||||
'If validation detects operator issues, they will be auto-fixed during n8n_create_workflow or n8n_update_partial_workflow'
|
||||
],
|
||||
pitfalls: [
|
||||
'Large workflows (100+ nodes) may take longer to validate',
|
||||
|
||||
@@ -4,15 +4,17 @@ export const n8nAutofixWorkflowDoc: ToolDocumentation = {
|
||||
name: 'n8n_autofix_workflow',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'Automatically fix common workflow validation errors - expression formats, typeVersions, error outputs, webhook paths',
|
||||
description: 'Automatically fix common workflow validation errors - expression formats, typeVersions, error outputs, webhook paths, and smart version upgrades',
|
||||
keyParameters: ['id', 'applyFixes'],
|
||||
example: 'n8n_autofix_workflow({id: "wf_abc123", applyFixes: false})',
|
||||
performance: 'Network-dependent (200-1000ms) - fetches, validates, and optionally updates workflow',
|
||||
performance: 'Network-dependent (200-1500ms) - fetches, validates, and optionally updates workflow with smart migrations',
|
||||
tips: [
|
||||
'Use applyFixes: false to preview changes before applying',
|
||||
'Set confidenceThreshold to control fix aggressiveness (high/medium/low)',
|
||||
'Supports fixing expression formats, typeVersion issues, error outputs, node type corrections, and webhook paths',
|
||||
'High-confidence fixes (≥90%) are safe for auto-application'
|
||||
'Supports expression formats, typeVersion issues, error outputs, node corrections, webhook paths, AND version upgrades',
|
||||
'High-confidence fixes (≥90%) are safe for auto-application',
|
||||
'Version upgrades include smart migration with breaking change detection',
|
||||
'Post-update guidance provides AI-friendly step-by-step instructions for manual changes'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
@@ -39,6 +41,20 @@ The auto-fixer can resolve:
|
||||
- Sets both 'path' parameter and 'webhookId' field to the same UUID
|
||||
- Ensures webhook nodes become functional with valid endpoints
|
||||
- High confidence fix as UUID generation is deterministic
|
||||
6. **Smart Version Upgrades** (NEW): Proactively upgrades nodes to their latest versions:
|
||||
- Detects outdated node versions and recommends upgrades
|
||||
- Applies smart migrations with auto-migratable property changes
|
||||
- Handles breaking changes intelligently (Execute Workflow v1.0→v1.1, Webhook v2.0→v2.1, etc.)
|
||||
- Generates UUIDs for required fields (webhookId), sets sensible defaults
|
||||
- HIGH confidence for non-breaking upgrades, MEDIUM for breaking changes with auto-migration
|
||||
- Example: Execute Workflow v1.0→v1.1 adds inputFieldMapping automatically
|
||||
7. **Version Migration Guidance** (NEW): Documents complex migrations requiring manual intervention:
|
||||
- Identifies breaking changes that cannot be auto-migrated
|
||||
- Provides AI-friendly post-update guidance with step-by-step instructions
|
||||
- Lists required actions by priority (CRITICAL, HIGH, MEDIUM, LOW)
|
||||
- Documents behavior changes and their impact
|
||||
- Estimates time required for manual migration steps
|
||||
- MEDIUM/LOW confidence - requires review before applying
|
||||
|
||||
The tool uses a confidence-based system to ensure safe fixes:
|
||||
- **High (≥90%)**: Safe to auto-apply (exact matches, known patterns)
|
||||
@@ -60,7 +76,7 @@ Requires N8N_API_URL and N8N_API_KEY environment variables to be configured.`,
|
||||
fixTypes: {
|
||||
type: 'array',
|
||||
required: false,
|
||||
description: 'Types of fixes to apply. Options: ["expression-format", "typeversion-correction", "error-output-config", "node-type-correction", "webhook-missing-path"]. Default: all types.'
|
||||
description: 'Types of fixes to apply. Options: ["expression-format", "typeversion-correction", "error-output-config", "node-type-correction", "webhook-missing-path", "typeversion-upgrade", "version-migration"]. Default: all types. NEW: "typeversion-upgrade" for smart version upgrades, "version-migration" for complex migration guidance.'
|
||||
},
|
||||
confidenceThreshold: {
|
||||
type: 'string',
|
||||
@@ -78,13 +94,21 @@ Requires N8N_API_URL and N8N_API_KEY environment variables to be configured.`,
|
||||
- fixes: Detailed list of individual fixes with before/after values
|
||||
- summary: Human-readable summary of fixes
|
||||
- stats: Statistics by fix type and confidence level
|
||||
- applied: Boolean indicating if fixes were applied (when applyFixes: true)`,
|
||||
- applied: Boolean indicating if fixes were applied (when applyFixes: true)
|
||||
- postUpdateGuidance: (NEW) Array of AI-friendly migration guidance for version upgrades, including:
|
||||
* Required actions by priority (CRITICAL, HIGH, MEDIUM, LOW)
|
||||
* Deprecated properties to remove
|
||||
* Behavior changes and their impact
|
||||
* Step-by-step migration instructions
|
||||
* Estimated time for manual changes`,
|
||||
examples: [
|
||||
'n8n_autofix_workflow({id: "wf_abc123"}) - Preview all possible fixes',
|
||||
'n8n_autofix_workflow({id: "wf_abc123"}) - Preview all possible fixes including version upgrades',
|
||||
'n8n_autofix_workflow({id: "wf_abc123", applyFixes: true}) - Apply all medium+ confidence fixes',
|
||||
'n8n_autofix_workflow({id: "wf_abc123", applyFixes: true, confidenceThreshold: "high"}) - Only apply high-confidence fixes',
|
||||
'n8n_autofix_workflow({id: "wf_abc123", fixTypes: ["expression-format"]}) - Only fix expression format issues',
|
||||
'n8n_autofix_workflow({id: "wf_abc123", fixTypes: ["webhook-missing-path"]}) - Only fix webhook path issues',
|
||||
'n8n_autofix_workflow({id: "wf_abc123", fixTypes: ["typeversion-upgrade"]}) - NEW: Only upgrade node versions with smart migrations',
|
||||
'n8n_autofix_workflow({id: "wf_abc123", fixTypes: ["typeversion-upgrade", "version-migration"]}) - NEW: Upgrade versions and provide migration guidance',
|
||||
'n8n_autofix_workflow({id: "wf_abc123", applyFixes: true, maxFixes: 10}) - Apply up to 10 fixes'
|
||||
],
|
||||
useCases: [
|
||||
@@ -94,16 +118,23 @@ Requires N8N_API_URL and N8N_API_KEY environment variables to be configured.`,
|
||||
'Cleaning up workflows before production deployment',
|
||||
'Batch fixing common issues across multiple workflows',
|
||||
'Migrating workflows between n8n instances with different versions',
|
||||
'Repairing webhook nodes that lost their path configuration'
|
||||
'Repairing webhook nodes that lost their path configuration',
|
||||
'Upgrading Execute Workflow nodes from v1.0 to v1.1+ with automatic inputFieldMapping',
|
||||
'Modernizing webhook nodes to v2.1+ with stable webhookId fields',
|
||||
'Proactively keeping workflows up-to-date with latest node versions',
|
||||
'Getting detailed migration guidance for complex breaking changes'
|
||||
],
|
||||
performance: 'Depends on workflow size and number of issues. Preview mode: 200-500ms. Apply mode: 500-1000ms for medium workflows. Node similarity matching is cached for 5 minutes for improved performance on repeated validations.',
|
||||
performance: 'Depends on workflow size and number of issues. Preview mode: 200-500ms. Apply mode: 500-1500ms for medium workflows with version upgrades. Node similarity matching and version metadata are cached for 5 minutes for improved performance on repeated validations.',
|
||||
bestPractices: [
|
||||
'Always preview fixes first (applyFixes: false) before applying',
|
||||
'Start with high confidence threshold for production workflows',
|
||||
'Review the fix summary to understand what changed',
|
||||
'Test workflows after auto-fixing to ensure expected behavior',
|
||||
'Use fixTypes parameter to target specific issue categories',
|
||||
'Keep maxFixes reasonable to avoid too many changes at once'
|
||||
'Keep maxFixes reasonable to avoid too many changes at once',
|
||||
'NEW: Review postUpdateGuidance for version upgrades - contains step-by-step migration instructions',
|
||||
'NEW: Test workflows after version upgrades - behavior may change even with successful auto-migration',
|
||||
'NEW: Apply version upgrades incrementally - start with high-confidence, non-breaking upgrades'
|
||||
],
|
||||
pitfalls: [
|
||||
'Some fixes may change workflow behavior - always test after fixing',
|
||||
@@ -112,7 +143,12 @@ Requires N8N_API_URL and N8N_API_KEY environment variables to be configured.`,
|
||||
'Node type corrections only work for known node types in the database',
|
||||
'Cannot fix structural issues like missing nodes or invalid connections',
|
||||
'TypeVersion downgrades might remove node features added in newer versions',
|
||||
'Generated webhook paths are new UUIDs - existing webhook URLs will change'
|
||||
'Generated webhook paths are new UUIDs - existing webhook URLs will change',
|
||||
'NEW: Version upgrades may introduce breaking changes - review postUpdateGuidance carefully',
|
||||
'NEW: Auto-migrated properties use sensible defaults which may not match your use case',
|
||||
'NEW: Execute Workflow v1.1+ requires explicit inputFieldMapping - automatic mapping uses empty array',
|
||||
'NEW: Some breaking changes cannot be auto-migrated and require manual intervention',
|
||||
'NEW: Version history is based on registry - unknown nodes cannot be upgraded'
|
||||
],
|
||||
relatedTools: [
|
||||
'n8n_validate_workflow',
|
||||
|
||||
@@ -11,7 +11,8 @@ export const n8nCreateWorkflowDoc: ToolDocumentation = {
|
||||
tips: [
|
||||
'Workflow created inactive',
|
||||
'Returns ID for future updates',
|
||||
'Validate first with validate_workflow'
|
||||
'Validate first with validate_workflow',
|
||||
'Auto-sanitization fixes operator structures and missing metadata during creation'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
@@ -90,7 +91,9 @@ n8n_create_workflow({
|
||||
'Workflows created in INACTIVE state - must activate separately',
|
||||
'Node IDs must be unique within workflow',
|
||||
'Credentials must be configured separately in n8n',
|
||||
'Node type names must include package prefix (e.g., "n8n-nodes-base.slack")'
|
||||
'Node type names must include package prefix (e.g., "n8n-nodes-base.slack")',
|
||||
'**Auto-sanitization runs on creation**: All nodes sanitized before workflow created (operator structures fixed, missing metadata added)',
|
||||
'**Auto-sanitization cannot prevent all failures**: Broken connections or invalid node configurations may still cause creation to fail'
|
||||
],
|
||||
relatedTools: ['validate_workflow', 'n8n_update_partial_workflow', 'n8n_trigger_webhook_workflow']
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const n8nUpdateFullWorkflowDoc: ToolDocumentation = {
|
||||
example: 'n8n_update_full_workflow({id: "wf_123", nodes: [...], connections: {...}})',
|
||||
performance: 'Network-dependent',
|
||||
tips: [
|
||||
'Include intent parameter in every call - helps to return better responses',
|
||||
'Must provide complete workflow',
|
||||
'Use update_partial for small changes',
|
||||
'Validate before updating'
|
||||
@@ -21,13 +22,15 @@ export const n8nUpdateFullWorkflowDoc: ToolDocumentation = {
|
||||
name: { type: 'string', description: 'New workflow name (optional)' },
|
||||
nodes: { type: 'array', description: 'Complete array of workflow nodes (required if modifying structure)' },
|
||||
connections: { type: 'object', description: 'Complete connections object (required if modifying structure)' },
|
||||
settings: { type: 'object', description: 'Workflow settings to update (timezone, error handling, etc.)' }
|
||||
settings: { type: 'object', description: 'Workflow settings to update (timezone, error handling, etc.)' },
|
||||
intent: { type: 'string', description: 'Intent of the change - helps to return better response. Include in every tool call. Example: "Migrate workflow to new node versions".' }
|
||||
},
|
||||
returns: 'Updated workflow object with all fields including the changes applied',
|
||||
examples: [
|
||||
'n8n_update_full_workflow({id: "abc", intent: "Rename workflow for clarity", name: "New Name"}) - Rename with intent',
|
||||
'n8n_update_full_workflow({id: "abc", name: "New Name"}) - Rename only',
|
||||
'n8n_update_full_workflow({id: "xyz", nodes: [...], connections: {...}}) - Full structure update',
|
||||
'const wf = n8n_get_workflow({id}); wf.nodes.push(newNode); n8n_update_full_workflow(wf); // Add node'
|
||||
'n8n_update_full_workflow({id: "xyz", intent: "Add error handling nodes", nodes: [...], connections: {...}}) - Full structure update',
|
||||
'const wf = n8n_get_workflow({id}); wf.nodes.push(newNode); n8n_update_full_workflow({...wf, intent: "Add data processing node"}); // Add node'
|
||||
],
|
||||
useCases: [
|
||||
'Major workflow restructuring',
|
||||
@@ -38,6 +41,7 @@ export const n8nUpdateFullWorkflowDoc: ToolDocumentation = {
|
||||
],
|
||||
performance: 'Network-dependent - typically 200-500ms. Larger workflows take longer. Consider update_partial for better performance.',
|
||||
bestPractices: [
|
||||
'Always include intent parameter - it helps provide better responses',
|
||||
'Get workflow first, modify, then update',
|
||||
'Validate with validate_workflow before updating',
|
||||
'Use update_partial for small changes',
|
||||
|
||||
@@ -4,11 +4,13 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
|
||||
name: 'n8n_update_partial_workflow',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
|
||||
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
|
||||
keyParameters: ['id', 'operations', 'continueOnError'],
|
||||
example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})',
|
||||
performance: 'Fast (50-200ms)',
|
||||
tips: [
|
||||
'ALWAYS provide intent parameter describing what you\'re doing (e.g., "Add error handling", "Fix webhook URL", "Connect Slack to error output")',
|
||||
'DON\'T use generic intent like "update workflow" or "partial update" - be specific about your goal',
|
||||
'Use rewireConnection to change connection targets',
|
||||
'Use branch="true"/"false" for IF nodes',
|
||||
'Use case=N for Switch nodes',
|
||||
@@ -17,11 +19,14 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
|
||||
'Use continueOnError mode for best-effort bulk operations',
|
||||
'Validate with validateOnly first',
|
||||
'For AI connections, specify sourceOutput type (ai_languageModel, ai_tool, etc.)',
|
||||
'Batch AI component connections for atomic updates'
|
||||
'Batch AI component connections for atomic updates',
|
||||
'Auto-sanitization: ALL nodes auto-fixed during updates (operator structures, missing metadata)',
|
||||
'Node renames automatically update all connection references - no manual connection operations needed',
|
||||
'Activate/deactivate workflows: Use activateWorkflow/deactivateWorkflow operations (requires activatable triggers like webhook/schedule)'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 15 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
|
||||
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 17 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
|
||||
|
||||
## Available Operations:
|
||||
|
||||
@@ -46,6 +51,10 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
|
||||
- **addTag**: Add a workflow tag
|
||||
- **removeTag**: Remove a workflow tag
|
||||
|
||||
### Workflow Activation Operations (2 types):
|
||||
- **activateWorkflow**: Activate the workflow to enable automatic execution via triggers
|
||||
- **deactivateWorkflow**: Deactivate the workflow to prevent automatic execution
|
||||
|
||||
## Smart Parameters for Multi-Output Nodes
|
||||
|
||||
For **IF nodes**, use semantic 'branch' parameter instead of technical sourceIndex:
|
||||
@@ -79,6 +88,10 @@ Full support for all 8 AI connection types used in n8n AI workflows:
|
||||
- Multiple tools: Batch multiple \`sourceOutput: "ai_tool"\` connections to one AI Agent
|
||||
- Vector retrieval: Chain ai_embedding → ai_vectorStore → ai_tool → AI Agent
|
||||
|
||||
**Important Notes**:
|
||||
- **AI nodes do NOT require main connections**: Nodes like OpenAI Chat Model, Postgres Chat Memory, Embeddings OpenAI, and Supabase Vector Store use AI-specific connection types exclusively. They should ONLY have connections like \`ai_languageModel\`, \`ai_memory\`, \`ai_embedding\`, or \`ai_tool\` - NOT \`main\` connections.
|
||||
- **Fixed in v2.21.1**: Validation now correctly recognizes AI nodes that only have AI-specific connections without requiring \`main\` connections (resolves issue #357).
|
||||
|
||||
**Best Practices**:
|
||||
- Always specify \`sourceOutput\` for AI connections (defaults to "main" if omitted)
|
||||
- Connect language model BEFORE creating/enabling AI Agent (validation requirement)
|
||||
@@ -94,7 +107,201 @@ The **cleanStaleConnections** operation automatically removes broken connection
|
||||
Set **continueOnError: true** to apply valid operations even if some fail. Returns detailed results showing which operations succeeded/failed. Perfect for bulk cleanup operations.
|
||||
|
||||
### Graceful Error Handling
|
||||
Add **ignoreErrors: true** to removeConnection operations to prevent failures when connections don't exist.`,
|
||||
Add **ignoreErrors: true** to removeConnection operations to prevent failures when connections don't exist.
|
||||
|
||||
## Auto-Sanitization System
|
||||
|
||||
### What Gets Auto-Fixed
|
||||
When ANY workflow update is made, ALL nodes in the workflow are automatically sanitized to ensure complete metadata and correct structure:
|
||||
|
||||
1. **Operator Structure Fixes**:
|
||||
- Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed
|
||||
- Unary operators (isEmpty, isNotEmpty, true, false) automatically get \`singleValue: true\` added
|
||||
- Invalid operator structures (e.g., \`{type: "isNotEmpty"}\`) are corrected to \`{type: "boolean", operation: "isNotEmpty"}\`
|
||||
|
||||
2. **Missing Metadata Added**:
|
||||
- IF nodes with conditions get complete \`conditions.options\` structure if missing
|
||||
- Switch nodes with conditions get complete \`conditions.options\` for all rules
|
||||
- Required fields: \`{version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}\`
|
||||
|
||||
### Sanitization Scope
|
||||
- Runs on **ALL nodes** in the workflow, not just modified ones
|
||||
- Triggered by ANY update operation (addNode, updateNode, addConnection, etc.)
|
||||
- Prevents workflow corruption that would make UI unrenderable
|
||||
|
||||
### Limitations
|
||||
Auto-sanitization CANNOT fix:
|
||||
- Broken connections (connections referencing non-existent nodes) - use \`cleanStaleConnections\`
|
||||
- Branch count mismatches (e.g., Switch with 3 rules but only 2 outputs) - requires manual connection fixes
|
||||
- Workflows in paradoxical corrupt states (API returns corrupt data, API rejects updates) - must recreate workflow
|
||||
|
||||
### Recovery Guidance
|
||||
If validation still fails after auto-sanitization:
|
||||
1. Check error details for specific issues
|
||||
2. Use \`validate_workflow\` to see all validation errors
|
||||
3. For connection issues, use \`cleanStaleConnections\` operation
|
||||
4. For branch mismatches, add missing output connections
|
||||
5. For paradoxical corrupted workflows, create new workflow and migrate nodes
|
||||
|
||||
## Automatic Connection Reference Updates
|
||||
|
||||
When you rename a node using **updateNode**, all connection references throughout the workflow are automatically updated. Both the connection source keys and target references are updated for all connection types (main, error, ai_tool, ai_languageModel, ai_memory, etc.) and all branch configurations (IF node branches, Switch node cases, error outputs).
|
||||
|
||||
### Basic Example
|
||||
\`\`\`javascript
|
||||
// Rename a node - connections update automatically
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_123",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeId: "node_abc",
|
||||
updates: { name: "Data Processor" }
|
||||
}]
|
||||
});
|
||||
// All incoming and outgoing connections now reference "Data Processor"
|
||||
\`\`\`
|
||||
|
||||
### Multi-Output Node Example
|
||||
\`\`\`javascript
|
||||
// Rename nodes in a branching workflow
|
||||
n8n_update_partial_workflow({
|
||||
id: "workflow_id",
|
||||
operations: [
|
||||
{
|
||||
type: "updateNode",
|
||||
nodeId: "if_node_id",
|
||||
updates: { name: "Value Checker" }
|
||||
},
|
||||
{
|
||||
type: "updateNode",
|
||||
nodeId: "error_node_id",
|
||||
updates: { name: "Error Handler" }
|
||||
}
|
||||
]
|
||||
});
|
||||
// IF node branches and error connections automatically updated
|
||||
\`\`\`
|
||||
|
||||
### Name Collision Protection
|
||||
Attempting to rename a node to an existing name returns a clear error:
|
||||
\`\`\`
|
||||
Cannot rename node "Old Name" to "New Name": A node with that name already exists (id: abc123...).
|
||||
Please choose a different name.
|
||||
\`\`\`
|
||||
|
||||
### Usage Notes
|
||||
- Simply rename nodes with updateNode - no manual connection operations needed
|
||||
- Multiple renames in one call work atomically
|
||||
- Can rename a node and add/remove connections using the new name in the same batch
|
||||
- Use \`validateOnly: true\` to preview effects before applying
|
||||
|
||||
## Removing Properties with undefined
|
||||
|
||||
To remove a property from a node, set its value to \`undefined\` in the updates object. This is essential when migrating from deprecated properties or cleaning up optional configuration fields.
|
||||
|
||||
### Why Use undefined?
|
||||
- **Property removal vs. null**: Setting a property to \`undefined\` removes it completely from the node object, while \`null\` sets the property to a null value
|
||||
- **Validation constraints**: Some properties are mutually exclusive (e.g., \`continueOnFail\` and \`onError\`). Simply setting one without removing the other will fail validation
|
||||
- **Deprecated property migration**: When n8n deprecates properties, you must remove the old property before the new one will work
|
||||
|
||||
### Basic Property Removal
|
||||
\`\`\`javascript
|
||||
// Remove error handling configuration
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_123",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "HTTP Request",
|
||||
updates: { onError: undefined }
|
||||
}]
|
||||
});
|
||||
|
||||
// Remove disabled flag
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_456",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeId: "node_abc",
|
||||
updates: { disabled: undefined }
|
||||
}]
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Nested Property Removal
|
||||
Use dot notation to remove nested properties:
|
||||
\`\`\`javascript
|
||||
// Remove nested parameter
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_789",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "API Request",
|
||||
updates: { "parameters.authentication": undefined }
|
||||
}]
|
||||
});
|
||||
|
||||
// Remove entire array property
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_012",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "HTTP Request",
|
||||
updates: { "parameters.headers": undefined }
|
||||
}]
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Migrating from Deprecated Properties
|
||||
Common scenario: replacing \`continueOnFail\` with \`onError\`:
|
||||
\`\`\`javascript
|
||||
// WRONG: Setting only the new property leaves the old one
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_123",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "HTTP Request",
|
||||
updates: { onError: "continueErrorOutput" }
|
||||
}]
|
||||
});
|
||||
// Error: continueOnFail and onError are mutually exclusive
|
||||
|
||||
// CORRECT: Remove the old property first
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_123",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "HTTP Request",
|
||||
updates: {
|
||||
continueOnFail: undefined,
|
||||
onError: "continueErrorOutput"
|
||||
}
|
||||
}]
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Batch Property Removal
|
||||
Remove multiple properties in one operation:
|
||||
\`\`\`javascript
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf_345",
|
||||
operations: [{
|
||||
type: "updateNode",
|
||||
nodeName: "Data Processor",
|
||||
updates: {
|
||||
continueOnFail: undefined,
|
||||
alwaysOutputData: undefined,
|
||||
"parameters.legacy_option": undefined
|
||||
}
|
||||
}]
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### When to Use undefined
|
||||
- Removing deprecated properties during migration
|
||||
- Cleaning up optional configuration flags
|
||||
- Resolving mutual exclusivity validation errors
|
||||
- Removing stale or unnecessary node metadata
|
||||
- Simplifying node configuration`,
|
||||
parameters: {
|
||||
id: { type: 'string', required: true, description: 'Workflow ID to update' },
|
||||
operations: {
|
||||
@@ -103,10 +310,12 @@ Add **ignoreErrors: true** to removeConnection operations to prevent failures wh
|
||||
description: 'Array of diff operations. Each must have "type" field and operation-specific properties. Nodes can be referenced by ID or name.'
|
||||
},
|
||||
validateOnly: { type: 'boolean', description: 'If true, only validate operations without applying them' },
|
||||
continueOnError: { type: 'boolean', description: 'If true, apply valid operations even if some fail (best-effort mode). Returns applied and failed operation indices. Default: false (atomic)' }
|
||||
continueOnError: { type: 'boolean', description: 'If true, apply valid operations even if some fail (best-effort mode). Returns applied and failed operation indices. Default: false (atomic)' },
|
||||
intent: { type: 'string', description: 'Intent of the change - helps to return better response. Include in every tool call. Example: "Add error handling for API failures".' }
|
||||
},
|
||||
returns: 'Updated workflow object or validation results if validateOnly=true',
|
||||
examples: [
|
||||
'// Include intent parameter for better responses\nn8n_update_partial_workflow({id: "abc", intent: "Add error handling for API failures", operations: [{type: "addConnection", source: "HTTP Request", target: "Error Handler"}]})',
|
||||
'// Add a basic node (minimal configuration)\nn8n_update_partial_workflow({id: "abc", operations: [{type: "addNode", node: {name: "Process Data", type: "n8n-nodes-base.set", position: [400, 300], parameters: {}}}]})',
|
||||
'// Add node with full configuration\nn8n_update_partial_workflow({id: "def", operations: [{type: "addNode", node: {name: "Send Slack Alert", type: "n8n-nodes-base.slack", position: [600, 300], typeVersion: 2, parameters: {resource: "message", operation: "post", channel: "#alerts", text: "Success!"}}}]})',
|
||||
'// Add node AND connect it (common pattern)\nn8n_update_partial_workflow({id: "ghi", operations: [\n {type: "addNode", node: {name: "HTTP Request", type: "n8n-nodes-base.httpRequest", position: [400, 300], parameters: {url: "https://api.example.com", method: "GET"}}},\n {type: "addConnection", source: "Webhook", target: "HTTP Request"}\n]})',
|
||||
@@ -127,11 +336,17 @@ Add **ignoreErrors: true** to removeConnection operations to prevent failures wh
|
||||
'// Connect memory to AI Agent\nn8n_update_partial_workflow({id: "ai3", operations: [{type: "addConnection", source: "Window Buffer Memory", target: "AI Agent", sourceOutput: "ai_memory"}]})',
|
||||
'// Connect output parser to AI Agent\nn8n_update_partial_workflow({id: "ai4", operations: [{type: "addConnection", source: "Structured Output Parser", target: "AI Agent", sourceOutput: "ai_outputParser"}]})',
|
||||
'// Complete AI Agent setup: Add language model, tools, and memory\nn8n_update_partial_workflow({id: "ai5", operations: [\n {type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"},\n {type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "Code Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "Window Buffer Memory", target: "AI Agent", sourceOutput: "ai_memory"}\n]})',
|
||||
'// Add fallback model to AI Agent (requires v2.1+)\nn8n_update_partial_workflow({id: "ai6", operations: [\n {type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel", targetIndex: 0},\n {type: "addConnection", source: "Anthropic Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel", targetIndex: 1}\n]})',
|
||||
'// Add fallback model to AI Agent for reliability\nn8n_update_partial_workflow({id: "ai6", operations: [\n {type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel", targetIndex: 0},\n {type: "addConnection", source: "Anthropic Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel", targetIndex: 1}\n]})',
|
||||
'// Vector Store setup: Connect embeddings and documents\nn8n_update_partial_workflow({id: "ai7", operations: [\n {type: "addConnection", source: "Embeddings OpenAI", target: "Pinecone Vector Store", sourceOutput: "ai_embedding"},\n {type: "addConnection", source: "Default Data Loader", target: "Pinecone Vector Store", sourceOutput: "ai_document"}\n]})',
|
||||
'// Connect Vector Store Tool to AI Agent (retrieval setup)\nn8n_update_partial_workflow({id: "ai8", operations: [\n {type: "addConnection", source: "Pinecone Vector Store", target: "Vector Store Tool", sourceOutput: "ai_vectorStore"},\n {type: "addConnection", source: "Vector Store Tool", target: "AI Agent", sourceOutput: "ai_tool"}\n]})',
|
||||
'// Rewire AI Agent to use different language model\nn8n_update_partial_workflow({id: "ai9", operations: [{type: "rewireConnection", source: "AI Agent", from: "OpenAI Chat Model", to: "Anthropic Chat Model", sourceOutput: "ai_languageModel"}]})',
|
||||
'// Replace all AI tools for an agent\nn8n_update_partial_workflow({id: "ai10", operations: [\n {type: "removeConnection", source: "Old Tool 1", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "removeConnection", source: "Old Tool 2", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New HTTP Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New Code Tool", target: "AI Agent", sourceOutput: "ai_tool"}\n]})'
|
||||
'// Replace all AI tools for an agent\nn8n_update_partial_workflow({id: "ai10", operations: [\n {type: "removeConnection", source: "Old Tool 1", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "removeConnection", source: "Old Tool 2", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New HTTP Tool", target: "AI Agent", sourceOutput: "ai_tool"},\n {type: "addConnection", source: "New Code Tool", target: "AI Agent", sourceOutput: "ai_tool"}\n]})',
|
||||
'\n// ============ REMOVING PROPERTIES EXAMPLES ============',
|
||||
'// Remove a simple property\nn8n_update_partial_workflow({id: "rm1", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {onError: undefined}}]})',
|
||||
'// Migrate from deprecated continueOnFail to onError\nn8n_update_partial_workflow({id: "rm2", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {continueOnFail: undefined, onError: "continueErrorOutput"}}]})',
|
||||
'// Remove nested property\nn8n_update_partial_workflow({id: "rm3", operations: [{type: "updateNode", nodeName: "API Request", updates: {"parameters.authentication": undefined}}]})',
|
||||
'// Remove multiple properties\nn8n_update_partial_workflow({id: "rm4", operations: [{type: "updateNode", nodeName: "Data Processor", updates: {continueOnFail: undefined, alwaysOutputData: undefined, "parameters.legacy_option": undefined}}]})',
|
||||
'// Remove entire array property\nn8n_update_partial_workflow({id: "rm5", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {"parameters.headers": undefined}}]})'
|
||||
],
|
||||
useCases: [
|
||||
'Rewire connections when replacing nodes',
|
||||
@@ -153,6 +368,7 @@ Add **ignoreErrors: true** to removeConnection operations to prevent failures wh
|
||||
],
|
||||
performance: 'Very fast - typically 50-200ms. Much faster than full updates as only changes are processed.',
|
||||
bestPractices: [
|
||||
'Always include intent parameter with specific description (e.g., "Add error handling to HTTP Request node", "Fix authentication flow", "Connect Slack notification to errors"). Avoid generic phrases like "update workflow" or "partial update"',
|
||||
'Use rewireConnection instead of remove+add for changing targets',
|
||||
'Use branch="true"/"false" for IF nodes instead of sourceIndex',
|
||||
'Use case=N for Switch nodes instead of sourceIndex',
|
||||
@@ -167,7 +383,11 @@ Add **ignoreErrors: true** to removeConnection operations to prevent failures wh
|
||||
'Connect language model BEFORE adding AI Agent to ensure validation passes',
|
||||
'Use targetIndex for fallback models (primary=0, fallback=1)',
|
||||
'Batch AI component connections in a single operation for atomicity',
|
||||
'Validate AI workflows after connection changes to catch configuration errors'
|
||||
'Validate AI workflows after connection changes to catch configuration errors',
|
||||
'To remove properties, set them to undefined (not null) in the updates object',
|
||||
'When migrating from deprecated properties, remove the old property and add the new one in the same operation',
|
||||
'Use undefined to resolve mutual exclusivity validation errors between properties',
|
||||
'Batch multiple property removals in a single updateNode operation for efficiency'
|
||||
],
|
||||
pitfalls: [
|
||||
'**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - will not work without n8n API access',
|
||||
@@ -180,8 +400,19 @@ Add **ignoreErrors: true** to removeConnection operations to prevent failures wh
|
||||
'Use "updates" property for updateNode operations: {type: "updateNode", updates: {...}}',
|
||||
'Smart parameters (branch, case) only work with IF and Switch nodes - ignored for other node types',
|
||||
'Explicit sourceIndex overrides smart parameters (branch, case) if both provided',
|
||||
'**CRITICAL**: For If nodes, ALWAYS use branch="true"/"false" instead of sourceIndex. Using sourceIndex=0 for multiple connections will put them ALL on the TRUE branch (main[0]), breaking your workflow logic!',
|
||||
'**CRITICAL**: For Switch nodes, ALWAYS use case=N instead of sourceIndex. Using same sourceIndex for multiple connections will put them on the same case output.',
|
||||
'cleanStaleConnections removes ALL broken connections - cannot be selective',
|
||||
'replaceConnections overwrites entire connections object - all previous connections lost'
|
||||
'replaceConnections overwrites entire connections object - all previous connections lost',
|
||||
'**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (isEmpty, isNotEmpty) automatically get singleValue:true added',
|
||||
'**Auto-sanitization runs on ALL nodes**: When ANY update is made, ALL nodes in the workflow are sanitized (not just modified ones)',
|
||||
'**Auto-sanitization cannot fix everything**: It fixes operator structures and missing metadata, but cannot fix broken connections or branch mismatches',
|
||||
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated',
|
||||
'Setting a property to null does NOT remove it - use undefined instead',
|
||||
'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with undefined',
|
||||
'Removing a required property may cause validation errors - check node documentation first',
|
||||
'Nested property removal with dot notation only removes the specific nested field, not the entire parent object',
|
||||
'Array index notation (e.g., "parameters.headers[0]") is not supported - remove the entire array property instead'
|
||||
],
|
||||
relatedTools: ['n8n_update_full_workflow', 'n8n_get_workflow', 'validate_workflow', 'tools_documentation']
|
||||
}
|
||||
|
||||
@@ -84,19 +84,22 @@ When working with Code nodes, always start by calling the relevant guide:
|
||||
|
||||
## Standard Workflow Pattern
|
||||
|
||||
⚠️ **CRITICAL**: Always call get_node() with detail='standard' FIRST before configuring any node!
|
||||
|
||||
1. **Find** the node you need:
|
||||
- search_nodes({query: "slack"}) - Search by keyword
|
||||
- list_nodes({category: "communication"}) - List by category
|
||||
- list_ai_tools() - List AI-capable nodes
|
||||
|
||||
2. **Configure** the node:
|
||||
- get_node_essentials("nodes-base.slack") - Get essential properties only (5KB)
|
||||
- get_node_info("nodes-base.slack") - Get complete schema (100KB+)
|
||||
2. **Configure** the node (ALWAYS START WITH STANDARD DETAIL):
|
||||
- ✅ get_node("nodes-base.slack", {detail: 'standard'}) - Get essential properties FIRST (~1-2KB, shows required fields)
|
||||
- get_node("nodes-base.slack", {detail: 'full'}) - Get complete schema only if standard insufficient (~100KB+)
|
||||
- get_node("nodes-base.slack", {detail: 'minimal'}) - Get basic metadata only (~200 tokens)
|
||||
- search_node_properties("nodes-base.slack", "auth") - Find specific properties
|
||||
|
||||
3. **Validate** before deployment:
|
||||
- validate_node_minimal("nodes-base.slack", config) - Check required fields
|
||||
- validate_node_operation("nodes-base.slack", config) - Full validation with fixes
|
||||
- validate_node_minimal("nodes-base.slack", config) - Check required fields (includes automatic structure validation)
|
||||
- validate_node_operation("nodes-base.slack", config) - Full validation with fixes (includes automatic structure validation)
|
||||
- validate_workflow(workflow) - Validate entire workflow
|
||||
|
||||
## Tool Categories
|
||||
@@ -107,14 +110,18 @@ When working with Code nodes, always start by calling the relevant guide:
|
||||
- list_ai_tools - List all AI-capable nodes with usage guidance
|
||||
|
||||
**Configuration Tools**
|
||||
- get_node_essentials - Returns 10-20 key properties with examples
|
||||
- get_node_info - Returns complete node schema with all properties
|
||||
- get_node - ✅ Unified node information tool with progressive detail levels:
|
||||
- detail='minimal': Basic metadata (~200 tokens)
|
||||
- detail='standard': Essential properties (default, ~1-2KB) - USE THIS FIRST!
|
||||
- detail='full': Complete schema (~100KB+, use only when standard insufficient)
|
||||
- mode='versions': View version history and breaking changes
|
||||
- includeTypeInfo=true: Add type structure metadata
|
||||
- search_node_properties - Search for specific properties within a node
|
||||
- get_property_dependencies - Analyze property visibility dependencies
|
||||
|
||||
**Validation Tools**
|
||||
- validate_node_minimal - Quick validation of required fields only
|
||||
- validate_node_operation - Full validation with operation awareness
|
||||
- validate_node_minimal - Quick validation of required fields (includes structure validation)
|
||||
- validate_node_operation - Full validation with operation awareness (includes structure validation)
|
||||
- validate_workflow - Complete workflow validation including connections
|
||||
|
||||
**Template Tools**
|
||||
@@ -130,9 +137,9 @@ When working with Code nodes, always start by calling the relevant guide:
|
||||
- n8n_trigger_webhook_workflow - Trigger workflow execution
|
||||
|
||||
## Performance Characteristics
|
||||
- Instant (<10ms): search_nodes, list_nodes, get_node_essentials
|
||||
- Instant (<10ms): search_nodes, list_nodes, get_node (minimal/standard)
|
||||
- Fast (<100ms): validate_node_minimal, get_node_for_task
|
||||
- Moderate (100-500ms): validate_workflow, get_node_info
|
||||
- Moderate (100-500ms): validate_workflow, get_node (full detail)
|
||||
- Network-dependent: All n8n_* tools
|
||||
|
||||
For comprehensive documentation on any tool:
|
||||
@@ -165,7 +172,7 @@ ${tools.map(toolName => {
|
||||
|
||||
## Usage Notes
|
||||
- All node types require the "nodes-base." or "nodes-langchain." prefix
|
||||
- Use get_node_essentials() first for most tasks (95% smaller than get_node_info)
|
||||
- Use get_node() with detail='standard' first for most tasks (~95% smaller than detail='full')
|
||||
- Validation profiles: minimal (editing), runtime (default), strict (deployment)
|
||||
- n8n API tools only available when N8N_API_URL and N8N_API_KEY are configured
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
description: 'Types of fixes to apply (default: all)',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['expression-format', 'typeversion-correction', 'error-output-config', 'node-type-correction', 'webhook-missing-path']
|
||||
enum: ['expression-format', 'typeversion-correction', 'error-output-config', 'node-type-correction', 'webhook-missing-path', 'typeversion-upgrade', 'version-migration']
|
||||
}
|
||||
},
|
||||
confidenceThreshold: {
|
||||
@@ -462,5 +462,59 @@ Examples:
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_workflow_versions',
|
||||
description: `Manage workflow version history, rollback, and cleanup. Six modes:
|
||||
- list: Show version history for a workflow
|
||||
- get: Get details of specific version
|
||||
- rollback: Restore workflow to previous version (creates backup first)
|
||||
- delete: Delete specific version or all versions for a workflow
|
||||
- prune: Manually trigger pruning to keep N most recent versions
|
||||
- truncate: Delete ALL versions for ALL workflows (requires confirmation)`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['list', 'get', 'rollback', 'delete', 'prune', 'truncate'],
|
||||
description: 'Operation mode'
|
||||
},
|
||||
workflowId: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID (required for list, rollback, delete, prune)'
|
||||
},
|
||||
versionId: {
|
||||
type: 'number',
|
||||
description: 'Version ID (required for get mode and single version delete, optional for rollback)'
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
default: 10,
|
||||
description: 'Max versions to return in list mode'
|
||||
},
|
||||
validateBefore: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Validate workflow structure before rollback'
|
||||
},
|
||||
deleteAll: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Delete all versions for workflow (delete mode only)'
|
||||
},
|
||||
maxVersions: {
|
||||
type: 'number',
|
||||
default: 10,
|
||||
description: 'Keep N most recent versions (prune mode only)'
|
||||
},
|
||||
confirmTruncate: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'REQUIRED: Must be true to truncate all versions (truncate mode only)'
|
||||
}
|
||||
},
|
||||
required: ['mode']
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -57,20 +57,6 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_info',
|
||||
description: `Get full node documentation. Pass nodeType as string with prefix. Example: nodeType="nodes-base.webhook"`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'Full type: "nodes-base.{name}" or "nodes-langchain.{name}". Examples: nodes-base.httpRequest, nodes-base.webhook, nodes-base.slack',
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description: `Search n8n nodes by keyword with optional real-world examples. Pass query as string. Example: query="webhook" or query="database". Returns max 20 results. Use includeExamples=true to get top 2 template configs per node.`,
|
||||
@@ -132,19 +118,44 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_essentials',
|
||||
description: `Get node essential info with optional real-world examples from templates. Pass nodeType as string with prefix. Example: nodeType="nodes-base.slack". Use includeExamples=true to get top 3 template configs.`,
|
||||
name: 'get_node',
|
||||
description: `Get node info with progressive detail levels. Detail: minimal (~200 tokens), standard (~1-2K, default), full (~3-8K). Version modes: versions (history), compare (diff), breaking (changes), migrations (auto-migrate). Supports includeTypeInfo and includeExamples. Use standard for most tasks.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'Full type: "nodes-base.httpRequest"',
|
||||
description: 'Full node type: "nodes-base.httpRequest" or "nodes-langchain.agent"',
|
||||
},
|
||||
detail: {
|
||||
type: 'string',
|
||||
enum: ['minimal', 'standard', 'full'],
|
||||
default: 'standard',
|
||||
description: 'Information detail level. standard=essential properties (recommended), full=everything',
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['info', 'versions', 'compare', 'breaking', 'migrations'],
|
||||
default: 'info',
|
||||
description: 'Operation mode. info=node information, versions=version history, compare/breaking/migrations=version comparison',
|
||||
},
|
||||
includeTypeInfo: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Include type structure metadata (type category, JS type, validation rules). Only applies to mode=info. Adds ~80-120 tokens per property.',
|
||||
},
|
||||
includeExamples: {
|
||||
type: 'boolean',
|
||||
description: 'Include top 3 real-world configuration examples from popular templates (default: false)',
|
||||
default: false,
|
||||
description: 'Include real-world configuration examples from templates. Only applies to mode=info with detail=standard. Adds ~200-400 tokens per example.',
|
||||
},
|
||||
fromVersion: {
|
||||
type: 'string',
|
||||
description: 'Source version for compare/breaking/migrations modes (e.g., "1.0")',
|
||||
},
|
||||
toVersion: {
|
||||
type: 'string',
|
||||
description: 'Target version for compare mode (e.g., "2.0"). Defaults to latest if omitted.',
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
|
||||
@@ -231,6 +231,7 @@ export class PropertyExtractor {
|
||||
required: prop.required,
|
||||
displayOptions: prop.displayOptions,
|
||||
typeOptions: prop.typeOptions,
|
||||
modes: prop.modes, // For resourceLocator type properties - modes are at top level
|
||||
noDataExpression: prop.noDataExpression
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -75,10 +75,15 @@ async function fetchTemplatesRobust() {
|
||||
|
||||
// Fetch detail
|
||||
const detail = await fetcher.fetchTemplateDetail(template.id);
|
||||
|
||||
// Save immediately
|
||||
repository.saveTemplate(template, detail);
|
||||
saved++;
|
||||
|
||||
if (detail !== null) {
|
||||
// Save immediately
|
||||
repository.saveTemplate(template, detail);
|
||||
saved++;
|
||||
} else {
|
||||
errors++;
|
||||
console.error(`\n❌ Failed to fetch template ${template.id} (${template.name}) after retries`);
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
@@ -164,7 +164,7 @@ async function testAutofix() {
|
||||
// Step 3: Generate fixes in preview mode
|
||||
logger.info('\nStep 3: Generating fixes (preview mode)...');
|
||||
const autoFixer = new WorkflowAutoFixer();
|
||||
const previewResult = autoFixer.generateFixes(
|
||||
const previewResult = await autoFixer.generateFixes(
|
||||
testWorkflow as any,
|
||||
validationResult,
|
||||
allFormatIssues,
|
||||
@@ -210,7 +210,7 @@ async function testAutofix() {
|
||||
logger.info('\n\n=== Testing Different Confidence Thresholds ===');
|
||||
|
||||
for (const threshold of ['high', 'medium', 'low'] as const) {
|
||||
const result = autoFixer.generateFixes(
|
||||
const result = await autoFixer.generateFixes(
|
||||
testWorkflow as any,
|
||||
validationResult,
|
||||
allFormatIssues,
|
||||
@@ -227,7 +227,7 @@ async function testAutofix() {
|
||||
|
||||
const fixTypes = ['expression-format', 'typeversion-correction', 'error-output-config'] as const;
|
||||
for (const fixType of fixTypes) {
|
||||
const result = autoFixer.generateFixes(
|
||||
const result = await autoFixer.generateFixes(
|
||||
testWorkflow as any,
|
||||
validationResult,
|
||||
allFormatIssues,
|
||||
|
||||
@@ -173,7 +173,7 @@ async function testNodeSimilarity() {
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const autoFixer = new WorkflowAutoFixer(repository);
|
||||
const fixResult = autoFixer.generateFixes(
|
||||
const fixResult = await autoFixer.generateFixes(
|
||||
testWorkflow as any,
|
||||
validationResult,
|
||||
[],
|
||||
|
||||
151
src/scripts/test-telemetry-mutations-verbose.ts
Normal file
151
src/scripts/test-telemetry-mutations-verbose.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Test telemetry mutations with enhanced logging
|
||||
* Verifies that mutations are properly tracked and persisted
|
||||
*/
|
||||
|
||||
import { telemetry } from '../telemetry/telemetry-manager.js';
|
||||
import { TelemetryConfigManager } from '../telemetry/config-manager.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
async function testMutations() {
|
||||
console.log('Starting verbose telemetry mutation test...\n');
|
||||
|
||||
const configManager = TelemetryConfigManager.getInstance();
|
||||
console.log('Telemetry config is enabled:', configManager.isEnabled());
|
||||
console.log('Telemetry config file:', configManager['configPath']);
|
||||
|
||||
// Test data with valid workflow structure
|
||||
const testMutation = {
|
||||
sessionId: 'test_session_' + Date.now(),
|
||||
toolName: 'n8n_update_partial_workflow',
|
||||
userIntent: 'Add a Merge node for data consolidation',
|
||||
operations: [
|
||||
{
|
||||
type: 'addNode',
|
||||
nodeId: 'Merge1',
|
||||
node: {
|
||||
id: 'Merge1',
|
||||
type: 'n8n-nodes-base.merge',
|
||||
name: 'Merge',
|
||||
position: [600, 200],
|
||||
parameters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'addConnection',
|
||||
source: 'previous_node',
|
||||
target: 'Merge1'
|
||||
}
|
||||
],
|
||||
workflowBefore: {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'previous_node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
name: 'When called',
|
||||
position: [300, 200],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
nodeIds: []
|
||||
},
|
||||
workflowAfter: {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'previous_node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
name: 'When called',
|
||||
position: [300, 200],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'Merge1',
|
||||
type: 'n8n-nodes-base.merge',
|
||||
name: 'Merge',
|
||||
position: [600, 200],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'previous_node': [
|
||||
{
|
||||
node: 'Merge1',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
source: 0,
|
||||
destination: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
nodeIds: []
|
||||
},
|
||||
mutationSuccess: true,
|
||||
durationMs: 125
|
||||
};
|
||||
|
||||
console.log('\nTest Mutation Data:');
|
||||
console.log('==================');
|
||||
console.log(JSON.stringify({
|
||||
intent: testMutation.userIntent,
|
||||
tool: testMutation.toolName,
|
||||
operationCount: testMutation.operations.length,
|
||||
sessionId: testMutation.sessionId
|
||||
}, null, 2));
|
||||
console.log('\n');
|
||||
|
||||
// Call trackWorkflowMutation
|
||||
console.log('Calling telemetry.trackWorkflowMutation...');
|
||||
try {
|
||||
await telemetry.trackWorkflowMutation(testMutation);
|
||||
console.log('✓ trackWorkflowMutation completed successfully\n');
|
||||
} catch (error) {
|
||||
console.error('✗ trackWorkflowMutation failed:', error);
|
||||
console.error('\n');
|
||||
}
|
||||
|
||||
// Check queue size before flush
|
||||
const metricsBeforeFlush = telemetry.getMetrics();
|
||||
console.log('Metrics before flush:');
|
||||
console.log('- mutationQueueSize:', metricsBeforeFlush.tracking.mutationQueueSize);
|
||||
console.log('- eventsTracked:', metricsBeforeFlush.processing.eventsTracked);
|
||||
console.log('- eventsFailed:', metricsBeforeFlush.processing.eventsFailed);
|
||||
console.log('\n');
|
||||
|
||||
// Flush telemetry with 10-second wait for Supabase
|
||||
console.log('Flushing telemetry (waiting 10 seconds for Supabase)...');
|
||||
try {
|
||||
await telemetry.flush();
|
||||
console.log('✓ Telemetry flush completed\n');
|
||||
} catch (error) {
|
||||
console.error('✗ Flush failed:', error);
|
||||
console.error('\n');
|
||||
}
|
||||
|
||||
// Wait a bit for async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Get final metrics
|
||||
const metricsAfterFlush = telemetry.getMetrics();
|
||||
console.log('Metrics after flush:');
|
||||
console.log('- mutationQueueSize:', metricsAfterFlush.tracking.mutationQueueSize);
|
||||
console.log('- eventsTracked:', metricsAfterFlush.processing.eventsTracked);
|
||||
console.log('- eventsFailed:', metricsAfterFlush.processing.eventsFailed);
|
||||
console.log('- batchesSent:', metricsAfterFlush.processing.batchesSent);
|
||||
console.log('- batchesFailed:', metricsAfterFlush.processing.batchesFailed);
|
||||
console.log('- circuitBreakerState:', metricsAfterFlush.processing.circuitBreakerState);
|
||||
console.log('\n');
|
||||
|
||||
console.log('Test completed. Check workflow_mutations table in Supabase.');
|
||||
}
|
||||
|
||||
testMutations().catch(error => {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
145
src/scripts/test-telemetry-mutations.ts
Normal file
145
src/scripts/test-telemetry-mutations.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Test telemetry mutations
|
||||
* Verifies that mutations are properly tracked and persisted
|
||||
*/
|
||||
|
||||
import { telemetry } from '../telemetry/telemetry-manager.js';
|
||||
import { TelemetryConfigManager } from '../telemetry/config-manager.js';
|
||||
|
||||
async function testMutations() {
|
||||
console.log('Starting telemetry mutation test...\n');
|
||||
|
||||
const configManager = TelemetryConfigManager.getInstance();
|
||||
|
||||
console.log('Telemetry Status:');
|
||||
console.log('================');
|
||||
console.log(configManager.getStatus());
|
||||
console.log('\n');
|
||||
|
||||
// Get initial metrics
|
||||
const metricsAfterInit = telemetry.getMetrics();
|
||||
console.log('Telemetry Metrics (After Init):');
|
||||
console.log('================================');
|
||||
console.log(JSON.stringify(metricsAfterInit, null, 2));
|
||||
console.log('\n');
|
||||
|
||||
// Test data mimicking actual mutation with valid workflow structure
|
||||
const testMutation = {
|
||||
sessionId: 'test_session_' + Date.now(),
|
||||
toolName: 'n8n_update_partial_workflow',
|
||||
userIntent: 'Add a Merge node for data consolidation',
|
||||
operations: [
|
||||
{
|
||||
type: 'addNode',
|
||||
nodeId: 'Merge1',
|
||||
node: {
|
||||
id: 'Merge1',
|
||||
type: 'n8n-nodes-base.merge',
|
||||
name: 'Merge',
|
||||
position: [600, 200],
|
||||
parameters: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'addConnection',
|
||||
source: 'previous_node',
|
||||
target: 'Merge1'
|
||||
}
|
||||
],
|
||||
workflowBefore: {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'previous_node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
name: 'When called',
|
||||
position: [300, 200],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
nodeIds: []
|
||||
},
|
||||
workflowAfter: {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'previous_node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
name: 'When called',
|
||||
position: [300, 200],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'Merge1',
|
||||
type: 'n8n-nodes-base.merge',
|
||||
name: 'Merge',
|
||||
position: [600, 200],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'previous_node': [
|
||||
{
|
||||
node: 'Merge1',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
source: 0,
|
||||
destination: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
nodeIds: []
|
||||
},
|
||||
mutationSuccess: true,
|
||||
durationMs: 125
|
||||
};
|
||||
|
||||
console.log('Test Mutation Data:');
|
||||
console.log('==================');
|
||||
console.log(JSON.stringify({
|
||||
intent: testMutation.userIntent,
|
||||
tool: testMutation.toolName,
|
||||
operationCount: testMutation.operations.length,
|
||||
sessionId: testMutation.sessionId
|
||||
}, null, 2));
|
||||
console.log('\n');
|
||||
|
||||
// Call trackWorkflowMutation
|
||||
console.log('Calling telemetry.trackWorkflowMutation...');
|
||||
try {
|
||||
await telemetry.trackWorkflowMutation(testMutation);
|
||||
console.log('✓ trackWorkflowMutation completed successfully\n');
|
||||
} catch (error) {
|
||||
console.error('✗ trackWorkflowMutation failed:', error);
|
||||
console.error('\n');
|
||||
}
|
||||
|
||||
// Flush telemetry
|
||||
console.log('Flushing telemetry...');
|
||||
try {
|
||||
await telemetry.flush();
|
||||
console.log('✓ Telemetry flushed successfully\n');
|
||||
} catch (error) {
|
||||
console.error('✗ Flush failed:', error);
|
||||
console.error('\n');
|
||||
}
|
||||
|
||||
// Get final metrics
|
||||
const metricsAfterFlush = telemetry.getMetrics();
|
||||
console.log('Telemetry Metrics (After Flush):');
|
||||
console.log('==================================');
|
||||
console.log(JSON.stringify(metricsAfterFlush, null, 2));
|
||||
console.log('\n');
|
||||
|
||||
console.log('Test completed. Check workflow_mutations table in Supabase.');
|
||||
}
|
||||
|
||||
testMutations().catch(error => {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -87,7 +87,7 @@ async function testWebhookAutofix() {
|
||||
// Step 2: Generate fixes (preview mode)
|
||||
logger.info('\nStep 2: Generating fixes in preview mode...');
|
||||
|
||||
const fixResult = autoFixer.generateFixes(
|
||||
const fixResult = await autoFixer.generateFixes(
|
||||
testWorkflow,
|
||||
validationResult,
|
||||
[], // No expression format issues to pass
|
||||
|
||||
321
src/services/breaking-change-detector.ts
Normal file
321
src/services/breaking-change-detector.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Breaking Change Detector
|
||||
*
|
||||
* Detects breaking changes between node versions by:
|
||||
* 1. Consulting the hardcoded breaking changes registry
|
||||
* 2. Dynamically comparing property schemas between versions
|
||||
* 3. Analyzing property requirement changes
|
||||
*
|
||||
* Used by the autofixer to intelligently upgrade node versions.
|
||||
*/
|
||||
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import {
|
||||
BREAKING_CHANGES_REGISTRY,
|
||||
BreakingChange,
|
||||
getBreakingChangesForNode,
|
||||
getAllChangesForNode
|
||||
} from './breaking-changes-registry';
|
||||
|
||||
export interface DetectedChange {
|
||||
propertyName: string;
|
||||
changeType: 'added' | 'removed' | 'renamed' | 'type_changed' | 'requirement_changed' | 'default_changed';
|
||||
isBreaking: boolean;
|
||||
oldValue?: any;
|
||||
newValue?: any;
|
||||
migrationHint: string;
|
||||
autoMigratable: boolean;
|
||||
migrationStrategy?: any;
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
source: 'registry' | 'dynamic'; // Where this change was detected
|
||||
}
|
||||
|
||||
export interface VersionUpgradeAnalysis {
|
||||
nodeType: string;
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
hasBreakingChanges: boolean;
|
||||
changes: DetectedChange[];
|
||||
autoMigratableCount: number;
|
||||
manualRequiredCount: number;
|
||||
overallSeverity: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export class BreakingChangeDetector {
|
||||
constructor(private nodeRepository: NodeRepository) {}
|
||||
|
||||
/**
|
||||
* Analyze a version upgrade and detect all changes
|
||||
*/
|
||||
async analyzeVersionUpgrade(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion: string
|
||||
): Promise<VersionUpgradeAnalysis> {
|
||||
// Get changes from registry
|
||||
const registryChanges = this.getRegistryChanges(nodeType, fromVersion, toVersion);
|
||||
|
||||
// Get dynamic changes by comparing schemas
|
||||
const dynamicChanges = this.detectDynamicChanges(nodeType, fromVersion, toVersion);
|
||||
|
||||
// Merge and deduplicate changes
|
||||
const allChanges = this.mergeChanges(registryChanges, dynamicChanges);
|
||||
|
||||
// Calculate statistics
|
||||
const hasBreakingChanges = allChanges.some(c => c.isBreaking);
|
||||
const autoMigratableCount = allChanges.filter(c => c.autoMigratable).length;
|
||||
const manualRequiredCount = allChanges.filter(c => !c.autoMigratable).length;
|
||||
|
||||
// Determine overall severity
|
||||
const overallSeverity = this.calculateOverallSeverity(allChanges);
|
||||
|
||||
// Generate recommendations
|
||||
const recommendations = this.generateRecommendations(allChanges);
|
||||
|
||||
return {
|
||||
nodeType,
|
||||
fromVersion,
|
||||
toVersion,
|
||||
hasBreakingChanges,
|
||||
changes: allChanges,
|
||||
autoMigratableCount,
|
||||
manualRequiredCount,
|
||||
overallSeverity,
|
||||
recommendations
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get changes from the hardcoded registry
|
||||
*/
|
||||
private getRegistryChanges(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion: string
|
||||
): DetectedChange[] {
|
||||
const registryChanges = getAllChangesForNode(nodeType, fromVersion, toVersion);
|
||||
|
||||
return registryChanges.map(change => ({
|
||||
propertyName: change.propertyName,
|
||||
changeType: change.changeType,
|
||||
isBreaking: change.isBreaking,
|
||||
oldValue: change.oldValue,
|
||||
newValue: change.newValue,
|
||||
migrationHint: change.migrationHint,
|
||||
autoMigratable: change.autoMigratable,
|
||||
migrationStrategy: change.migrationStrategy,
|
||||
severity: change.severity,
|
||||
source: 'registry' as const
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically detect changes by comparing property schemas
|
||||
*/
|
||||
private detectDynamicChanges(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion: string
|
||||
): DetectedChange[] {
|
||||
// Get both versions from the database
|
||||
const oldVersionData = this.nodeRepository.getNodeVersion(nodeType, fromVersion);
|
||||
const newVersionData = this.nodeRepository.getNodeVersion(nodeType, toVersion);
|
||||
|
||||
if (!oldVersionData || !newVersionData) {
|
||||
return []; // Can't detect dynamic changes without version data
|
||||
}
|
||||
|
||||
const changes: DetectedChange[] = [];
|
||||
|
||||
// Compare properties schemas
|
||||
const oldProps = this.flattenProperties(oldVersionData.propertiesSchema || []);
|
||||
const newProps = this.flattenProperties(newVersionData.propertiesSchema || []);
|
||||
|
||||
// Detect added properties
|
||||
for (const propName of Object.keys(newProps)) {
|
||||
if (!oldProps[propName]) {
|
||||
const prop = newProps[propName];
|
||||
const isRequired = prop.required === true;
|
||||
|
||||
changes.push({
|
||||
propertyName: propName,
|
||||
changeType: 'added',
|
||||
isBreaking: isRequired, // Breaking if required
|
||||
newValue: prop.type || 'unknown',
|
||||
migrationHint: isRequired
|
||||
? `Property "${propName}" is now required in v${toVersion}. Provide a value to prevent validation errors.`
|
||||
: `Property "${propName}" was added in v${toVersion}. Optional parameter, safe to ignore if not needed.`,
|
||||
autoMigratable: !isRequired, // Can auto-add with default if not required
|
||||
migrationStrategy: !isRequired
|
||||
? {
|
||||
type: 'add_property',
|
||||
defaultValue: prop.default || null
|
||||
}
|
||||
: undefined,
|
||||
severity: isRequired ? 'HIGH' : 'LOW',
|
||||
source: 'dynamic'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Detect removed properties
|
||||
for (const propName of Object.keys(oldProps)) {
|
||||
if (!newProps[propName]) {
|
||||
changes.push({
|
||||
propertyName: propName,
|
||||
changeType: 'removed',
|
||||
isBreaking: true, // Removal is always breaking
|
||||
oldValue: oldProps[propName].type || 'unknown',
|
||||
migrationHint: `Property "${propName}" was removed in v${toVersion}. Remove this property from your configuration.`,
|
||||
autoMigratable: true, // Can auto-remove
|
||||
migrationStrategy: {
|
||||
type: 'remove_property'
|
||||
},
|
||||
severity: 'MEDIUM',
|
||||
source: 'dynamic'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Detect requirement changes
|
||||
for (const propName of Object.keys(newProps)) {
|
||||
if (oldProps[propName]) {
|
||||
const oldRequired = oldProps[propName].required === true;
|
||||
const newRequired = newProps[propName].required === true;
|
||||
|
||||
if (oldRequired !== newRequired) {
|
||||
changes.push({
|
||||
propertyName: propName,
|
||||
changeType: 'requirement_changed',
|
||||
isBreaking: newRequired && !oldRequired, // Breaking if became required
|
||||
oldValue: oldRequired ? 'required' : 'optional',
|
||||
newValue: newRequired ? 'required' : 'optional',
|
||||
migrationHint: newRequired
|
||||
? `Property "${propName}" is now required in v${toVersion}. Ensure a value is provided.`
|
||||
: `Property "${propName}" is now optional in v${toVersion}.`,
|
||||
autoMigratable: false, // Requirement changes need manual review
|
||||
severity: newRequired ? 'HIGH' : 'LOW',
|
||||
source: 'dynamic'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten nested properties into a map for easy comparison
|
||||
*/
|
||||
private flattenProperties(properties: any[], prefix: string = ''): Record<string, any> {
|
||||
const flat: Record<string, any> = {};
|
||||
|
||||
for (const prop of properties) {
|
||||
if (!prop.name && !prop.displayName) continue;
|
||||
|
||||
const propName = prop.name || prop.displayName;
|
||||
const fullPath = prefix ? `${prefix}.${propName}` : propName;
|
||||
|
||||
flat[fullPath] = prop;
|
||||
|
||||
// Recursively flatten nested options
|
||||
if (prop.options && Array.isArray(prop.options)) {
|
||||
Object.assign(flat, this.flattenProperties(prop.options, fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge registry and dynamic changes, avoiding duplicates
|
||||
*/
|
||||
private mergeChanges(
|
||||
registryChanges: DetectedChange[],
|
||||
dynamicChanges: DetectedChange[]
|
||||
): DetectedChange[] {
|
||||
const merged = [...registryChanges];
|
||||
|
||||
// Add dynamic changes that aren't already in registry
|
||||
for (const dynamicChange of dynamicChanges) {
|
||||
const existsInRegistry = registryChanges.some(
|
||||
rc => rc.propertyName === dynamicChange.propertyName &&
|
||||
rc.changeType === dynamicChange.changeType
|
||||
);
|
||||
|
||||
if (!existsInRegistry) {
|
||||
merged.push(dynamicChange);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by severity (HIGH -> MEDIUM -> LOW)
|
||||
const severityOrder = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
||||
merged.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall severity of the upgrade
|
||||
*/
|
||||
private calculateOverallSeverity(changes: DetectedChange[]): 'LOW' | 'MEDIUM' | 'HIGH' {
|
||||
if (changes.some(c => c.severity === 'HIGH')) return 'HIGH';
|
||||
if (changes.some(c => c.severity === 'MEDIUM')) return 'MEDIUM';
|
||||
return 'LOW';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate actionable recommendations for the upgrade
|
||||
*/
|
||||
private generateRecommendations(changes: DetectedChange[]): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
const breakingChanges = changes.filter(c => c.isBreaking);
|
||||
const autoMigratable = changes.filter(c => c.autoMigratable);
|
||||
const manualRequired = changes.filter(c => !c.autoMigratable);
|
||||
|
||||
if (breakingChanges.length === 0) {
|
||||
recommendations.push('✓ No breaking changes detected. This upgrade should be safe.');
|
||||
} else {
|
||||
recommendations.push(
|
||||
`⚠ ${breakingChanges.length} breaking change(s) detected. Review carefully before applying.`
|
||||
);
|
||||
}
|
||||
|
||||
if (autoMigratable.length > 0) {
|
||||
recommendations.push(
|
||||
`✓ ${autoMigratable.length} change(s) can be automatically migrated.`
|
||||
);
|
||||
}
|
||||
|
||||
if (manualRequired.length > 0) {
|
||||
recommendations.push(
|
||||
`✋ ${manualRequired.length} change(s) require manual intervention.`
|
||||
);
|
||||
|
||||
// List specific manual changes
|
||||
for (const change of manualRequired) {
|
||||
recommendations.push(` - ${change.propertyName}: ${change.migrationHint}`);
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: does this upgrade have breaking changes?
|
||||
*/
|
||||
hasBreakingChanges(nodeType: string, fromVersion: string, toVersion: string): boolean {
|
||||
const registryChanges = getBreakingChangesForNode(nodeType, fromVersion, toVersion);
|
||||
return registryChanges.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get simple list of property names that changed
|
||||
*/
|
||||
getChangedProperties(nodeType: string, fromVersion: string, toVersion: string): string[] {
|
||||
const registryChanges = getAllChangesForNode(nodeType, fromVersion, toVersion);
|
||||
return registryChanges.map(c => c.propertyName);
|
||||
}
|
||||
}
|
||||
315
src/services/breaking-changes-registry.ts
Normal file
315
src/services/breaking-changes-registry.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Breaking Changes Registry
|
||||
*
|
||||
* Central registry of known breaking changes between node versions.
|
||||
* Used by the autofixer to detect and migrate version upgrades intelligently.
|
||||
*
|
||||
* Each entry defines:
|
||||
* - Which versions are affected
|
||||
* - What properties changed
|
||||
* - Whether it's auto-migratable
|
||||
* - Migration strategies and hints
|
||||
*/
|
||||
|
||||
export interface BreakingChange {
|
||||
nodeType: string;
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
propertyName: string;
|
||||
changeType: 'added' | 'removed' | 'renamed' | 'type_changed' | 'requirement_changed' | 'default_changed';
|
||||
isBreaking: boolean;
|
||||
oldValue?: string;
|
||||
newValue?: string;
|
||||
migrationHint: string;
|
||||
autoMigratable: boolean;
|
||||
migrationStrategy?: {
|
||||
type: 'add_property' | 'remove_property' | 'rename_property' | 'set_default';
|
||||
defaultValue?: any;
|
||||
sourceProperty?: string;
|
||||
targetProperty?: string;
|
||||
};
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of known breaking changes across all n8n nodes
|
||||
*/
|
||||
export const BREAKING_CHANGES_REGISTRY: BreakingChange[] = [
|
||||
// ==========================================
|
||||
// Execute Workflow Node
|
||||
// ==========================================
|
||||
{
|
||||
nodeType: 'n8n-nodes-base.executeWorkflow',
|
||||
fromVersion: '1.0',
|
||||
toVersion: '1.1',
|
||||
propertyName: 'parameters.inputFieldMapping',
|
||||
changeType: 'added',
|
||||
isBreaking: true,
|
||||
migrationHint: 'In v1.1+, the Execute Workflow node requires explicit field mapping to pass data to sub-workflows. Add an "inputFieldMapping" object with "mappings" array defining how to map fields from parent to child workflow.',
|
||||
autoMigratable: true,
|
||||
migrationStrategy: {
|
||||
type: 'add_property',
|
||||
defaultValue: {
|
||||
mappings: []
|
||||
}
|
||||
},
|
||||
severity: 'HIGH'
|
||||
},
|
||||
{
|
||||
nodeType: 'n8n-nodes-base.executeWorkflow',
|
||||
fromVersion: '1.0',
|
||||
toVersion: '1.1',
|
||||
propertyName: 'parameters.mode',
|
||||
changeType: 'requirement_changed',
|
||||
isBreaking: false,
|
||||
migrationHint: 'The "mode" parameter behavior changed in v1.1. Default is now "static" instead of "list". Ensure your workflow ID specification matches the selected mode.',
|
||||
autoMigratable: false,
|
||||
severity: 'MEDIUM'
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// Webhook Node
|
||||
// ==========================================
|
||||
{
|
||||
nodeType: 'n8n-nodes-base.webhook',
|
||||
fromVersion: '2.0',
|
||||
toVersion: '2.1',
|
||||
propertyName: 'webhookId',
|
||||
changeType: 'added',
|
||||
isBreaking: true,
|
||||
migrationHint: 'In v2.1+, webhooks require a unique "webhookId" field in addition to the path. This ensures webhook persistence across workflow updates. A UUID will be auto-generated if not provided.',
|
||||
autoMigratable: true,
|
||||
migrationStrategy: {
|
||||
type: 'add_property',
|
||||
defaultValue: null // Will be generated as UUID at runtime
|
||||
},
|
||||
severity: 'HIGH'
|
||||
},
|
||||
{
|
||||
nodeType: 'n8n-nodes-base.webhook',
|
||||
fromVersion: '1.0',
|
||||
toVersion: '2.0',
|
||||
propertyName: 'parameters.path',
|
||||
changeType: 'requirement_changed',
|
||||
isBreaking: true,
|
||||
migrationHint: 'In v2.0+, the webhook path must be explicitly defined and cannot be empty. Ensure a valid path is set.',
|
||||
autoMigratable: false,
|
||||
severity: 'HIGH'
|
||||
},
|
||||
{
|
||||
nodeType: 'n8n-nodes-base.webhook',
|
||||
fromVersion: '1.0',
|
||||
toVersion: '2.0',
|
||||
propertyName: 'parameters.responseMode',
|
||||
changeType: 'added',
|
||||
isBreaking: false,
|
||||
migrationHint: 'v2.0 introduces a "responseMode" parameter to control how the webhook responds. Default is "onReceived" (immediate response). Use "lastNode" to wait for workflow completion.',
|
||||
autoMigratable: true,
|
||||
migrationStrategy: {
|
||||
type: 'add_property',
|
||||
defaultValue: 'onReceived'
|
||||
},
|
||||
severity: 'LOW'
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// HTTP Request Node
|
||||
// ==========================================
|
||||
{
|
||||
nodeType: 'n8n-nodes-base.httpRequest',
|
||||
fromVersion: '4.1',
|
||||
toVersion: '4.2',
|
||||
propertyName: 'parameters.sendBody',
|
||||
changeType: 'requirement_changed',
|
||||
isBreaking: false,
|
||||
migrationHint: 'In v4.2+, "sendBody" must be explicitly set to true for POST/PUT/PATCH requests to include a body. Previous versions had implicit body sending.',
|
||||
autoMigratable: true,
|
||||
migrationStrategy: {
|
||||
type: 'add_property',
|
||||
defaultValue: true
|
||||
},
|
||||
severity: 'MEDIUM'
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// Code Node (JavaScript)
|
||||
// ==========================================
|
||||
{
|
||||
nodeType: 'n8n-nodes-base.code',
|
||||
fromVersion: '1.0',
|
||||
toVersion: '2.0',
|
||||
propertyName: 'parameters.mode',
|
||||
changeType: 'added',
|
||||
isBreaking: false,
|
||||
migrationHint: 'v2.0 introduces execution modes: "runOnceForAllItems" (default) and "runOnceForEachItem". The default mode processes all items at once, which may differ from v1.0 behavior.',
|
||||
autoMigratable: true,
|
||||
migrationStrategy: {
|
||||
type: 'add_property',
|
||||
defaultValue: 'runOnceForAllItems'
|
||||
},
|
||||
severity: 'MEDIUM'
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// Schedule Trigger Node
|
||||
// ==========================================
|
||||
{
|
||||
nodeType: 'n8n-nodes-base.scheduleTrigger',
|
||||
fromVersion: '1.0',
|
||||
toVersion: '1.1',
|
||||
propertyName: 'parameters.rule.interval',
|
||||
changeType: 'type_changed',
|
||||
isBreaking: true,
|
||||
oldValue: 'string',
|
||||
newValue: 'array',
|
||||
migrationHint: 'In v1.1+, the interval parameter changed from a single string to an array of interval objects. Convert your single interval to an array format: [{field: "hours", value: 1}]',
|
||||
autoMigratable: false,
|
||||
severity: 'HIGH'
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// Error Handling (Global Change)
|
||||
// ==========================================
|
||||
{
|
||||
nodeType: '*', // Applies to all nodes
|
||||
fromVersion: '1.0',
|
||||
toVersion: '2.0',
|
||||
propertyName: 'continueOnFail',
|
||||
changeType: 'removed',
|
||||
isBreaking: false,
|
||||
migrationHint: 'The "continueOnFail" property is deprecated. Use "onError" instead with value "continueErrorOutput" or "continueRegularOutput".',
|
||||
autoMigratable: true,
|
||||
migrationStrategy: {
|
||||
type: 'rename_property',
|
||||
sourceProperty: 'continueOnFail',
|
||||
targetProperty: 'onError',
|
||||
defaultValue: 'continueErrorOutput'
|
||||
},
|
||||
severity: 'MEDIUM'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Get breaking changes for a specific node type and version upgrade
|
||||
*/
|
||||
export function getBreakingChangesForNode(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion: string
|
||||
): BreakingChange[] {
|
||||
return BREAKING_CHANGES_REGISTRY.filter(change => {
|
||||
// Match exact node type or wildcard (*)
|
||||
const nodeMatches = change.nodeType === nodeType || change.nodeType === '*';
|
||||
|
||||
// Check if version range matches
|
||||
const versionMatches =
|
||||
compareVersions(fromVersion, change.fromVersion) >= 0 &&
|
||||
compareVersions(toVersion, change.toVersion) <= 0;
|
||||
|
||||
return nodeMatches && versionMatches && change.isBreaking;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all changes (breaking and non-breaking) for a version upgrade
|
||||
*/
|
||||
export function getAllChangesForNode(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion: string
|
||||
): BreakingChange[] {
|
||||
return BREAKING_CHANGES_REGISTRY.filter(change => {
|
||||
const nodeMatches = change.nodeType === nodeType || change.nodeType === '*';
|
||||
const versionMatches =
|
||||
compareVersions(fromVersion, change.fromVersion) >= 0 &&
|
||||
compareVersions(toVersion, change.toVersion) <= 0;
|
||||
|
||||
return nodeMatches && versionMatches;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auto-migratable changes for a version upgrade
|
||||
*/
|
||||
export function getAutoMigratableChanges(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion: string
|
||||
): BreakingChange[] {
|
||||
return getAllChangesForNode(nodeType, fromVersion, toVersion).filter(
|
||||
change => change.autoMigratable
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific node has known breaking changes for a version upgrade
|
||||
*/
|
||||
export function hasBreakingChanges(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion: string
|
||||
): boolean {
|
||||
return getBreakingChangesForNode(nodeType, fromVersion, toVersion).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get migration hints for a version upgrade
|
||||
*/
|
||||
export function getMigrationHints(
|
||||
nodeType: string,
|
||||
fromVersion: string,
|
||||
toVersion: string
|
||||
): string[] {
|
||||
const changes = getAllChangesForNode(nodeType, fromVersion, toVersion);
|
||||
return changes.map(change => change.migrationHint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple version comparison
|
||||
* Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
*/
|
||||
function compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const p1 = parts1[i] || 0;
|
||||
const p2 = parts2[i] || 0;
|
||||
|
||||
if (p1 < p2) return -1;
|
||||
if (p1 > p2) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nodes with known version migrations
|
||||
*/
|
||||
export function getNodesWithVersionMigrations(): string[] {
|
||||
const nodeTypes = new Set<string>();
|
||||
|
||||
BREAKING_CHANGES_REGISTRY.forEach(change => {
|
||||
if (change.nodeType !== '*') {
|
||||
nodeTypes.add(change.nodeType);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(nodeTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all versions tracked for a specific node
|
||||
*/
|
||||
export function getTrackedVersionsForNode(nodeType: string): string[] {
|
||||
const versions = new Set<string>();
|
||||
|
||||
BREAKING_CHANGES_REGISTRY
|
||||
.filter(change => change.nodeType === nodeType || change.nodeType === '*')
|
||||
.forEach(change => {
|
||||
versions.add(change.fromVersion);
|
||||
versions.add(change.toVersion);
|
||||
});
|
||||
|
||||
return Array.from(versions).sort((a, b) => compareVersions(a, b));
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Configuration Validator Service
|
||||
*
|
||||
*
|
||||
* Validates node configurations to catch errors before execution.
|
||||
* Provides helpful suggestions and identifies missing or misconfigured properties.
|
||||
*/
|
||||
|
||||
import { shouldSkipLiteralValidation } from '../utils/expression-utils.js';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
@@ -268,16 +270,46 @@ export class ConfigValidator {
|
||||
type: 'invalid_type',
|
||||
property: `${key}.mode`,
|
||||
message: `resourceLocator '${key}.mode' must be a string, got ${typeof value.mode}`,
|
||||
fix: `Set mode to "list" or "id"`
|
||||
});
|
||||
} else if (!['list', 'id', 'url'].includes(value.mode)) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: `${key}.mode`,
|
||||
message: `resourceLocator '${key}.mode' must be 'list', 'id', or 'url', got '${value.mode}'`,
|
||||
fix: `Change mode to "list", "id", or "url"`
|
||||
fix: `Set mode to a valid string value`
|
||||
});
|
||||
} else if (prop.modes) {
|
||||
// Schema-based validation: Check if mode exists in the modes definition
|
||||
// In n8n, modes are defined at the top level of resourceLocator properties
|
||||
// Modes can be defined in different ways:
|
||||
// 1. Array of mode objects: [{name: 'list', ...}, {name: 'id', ...}, {name: 'name', ...}]
|
||||
// 2. Object with mode keys: { list: {...}, id: {...}, url: {...}, name: {...} }
|
||||
const modes = prop.modes;
|
||||
|
||||
// Validate modes structure before processing to prevent crashes
|
||||
if (!modes || typeof modes !== 'object') {
|
||||
// Invalid schema structure - skip validation to prevent false positives
|
||||
continue;
|
||||
}
|
||||
|
||||
let allowedModes: string[] = [];
|
||||
|
||||
if (Array.isArray(modes)) {
|
||||
// Array format (most common in n8n): extract name property from each mode object
|
||||
allowedModes = modes
|
||||
.map(m => (typeof m === 'object' && m !== null) ? m.name : m)
|
||||
.filter(m => typeof m === 'string' && m.length > 0);
|
||||
} else {
|
||||
// Object format: extract keys as mode names
|
||||
allowedModes = Object.keys(modes).filter(k => k.length > 0);
|
||||
}
|
||||
|
||||
// Only validate if we successfully extracted modes
|
||||
if (allowedModes.length > 0 && !allowedModes.includes(value.mode)) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: `${key}.mode`,
|
||||
message: `resourceLocator '${key}.mode' must be one of [${allowedModes.join(', ')}], got '${value.mode}'`,
|
||||
fix: `Change mode to one of: ${allowedModes.join(', ')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
// If no modes defined at property level, skip mode validation
|
||||
// This prevents false positives for nodes with dynamic/runtime-determined modes
|
||||
|
||||
if (value.value === undefined) {
|
||||
errors.push({
|
||||
@@ -351,13 +383,16 @@ export class ConfigValidator {
|
||||
): void {
|
||||
// URL validation
|
||||
if (config.url && typeof config.url === 'string') {
|
||||
if (!config.url.startsWith('http://') && !config.url.startsWith('https://')) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: 'url',
|
||||
message: 'URL must start with http:// or https://',
|
||||
fix: 'Add https:// to the beginning of your URL'
|
||||
});
|
||||
// Skip validation for expressions - they will be evaluated at runtime
|
||||
if (!shouldSkipLiteralValidation(config.url)) {
|
||||
if (!config.url.startsWith('http://') && !config.url.startsWith('https://')) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: 'url',
|
||||
message: 'URL must start with http:// or https://',
|
||||
fix: 'Add https:// to the beginning of your URL'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,15 +422,19 @@ export class ConfigValidator {
|
||||
|
||||
// JSON body validation
|
||||
if (config.sendBody && config.contentType === 'json' && config.jsonBody) {
|
||||
try {
|
||||
JSON.parse(config.jsonBody);
|
||||
} catch (e) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: 'jsonBody',
|
||||
message: 'jsonBody contains invalid JSON',
|
||||
fix: 'Ensure jsonBody contains valid JSON syntax'
|
||||
});
|
||||
// Skip validation for expressions - they will be evaluated at runtime
|
||||
if (!shouldSkipLiteralValidation(config.jsonBody)) {
|
||||
try {
|
||||
JSON.parse(config.jsonBody);
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'Unknown parsing error';
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: 'jsonBody',
|
||||
message: `jsonBody contains invalid JSON: ${errorMsg}`,
|
||||
fix: 'Fix JSON syntax error and ensure valid JSON format'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import { ResourceSimilarityService } from './resource-similarity-service';
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { DatabaseAdapter } from '../database/database-adapter';
|
||||
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
|
||||
import { TypeStructureService } from './type-structure-service';
|
||||
import type { NodePropertyTypes } from 'n8n-workflow';
|
||||
|
||||
export type ValidationMode = 'full' | 'operation' | 'minimal';
|
||||
export type ValidationProfile = 'strict' | 'runtime' | 'ai-friendly' | 'minimal';
|
||||
@@ -111,7 +113,7 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
this.applyProfileFilters(enhancedResult, profile);
|
||||
|
||||
// Add operation-specific enhancements
|
||||
this.addOperationSpecificEnhancements(nodeType, config, enhancedResult);
|
||||
this.addOperationSpecificEnhancements(nodeType, config, filteredProperties, enhancedResult);
|
||||
|
||||
// Deduplicate errors
|
||||
enhancedResult.errors = this.deduplicateErrors(enhancedResult.errors);
|
||||
@@ -247,6 +249,7 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
private static addOperationSpecificEnhancements(
|
||||
nodeType: string,
|
||||
config: Record<string, any>,
|
||||
properties: any[],
|
||||
result: EnhancedValidationResult
|
||||
): void {
|
||||
// Type safety check - this should never happen with proper validation
|
||||
@@ -263,6 +266,9 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
// Validate resource and operation using similarity services
|
||||
this.validateResourceAndOperation(nodeType, config, result);
|
||||
|
||||
// Validate special type structures (filter, resourceMapper, assignmentCollection, resourceLocator)
|
||||
this.validateSpecialTypeStructures(config, properties, result);
|
||||
|
||||
// First, validate fixedCollection properties for known problematic nodes
|
||||
this.validateFixedCollectionStructures(nodeType, config, result);
|
||||
|
||||
@@ -318,7 +324,15 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
case 'nodes-base.mysql':
|
||||
NodeSpecificValidators.validateMySQL(context);
|
||||
break;
|
||||
|
||||
|
||||
case 'nodes-langchain.agent':
|
||||
NodeSpecificValidators.validateAIAgent(context);
|
||||
break;
|
||||
|
||||
case 'nodes-base.set':
|
||||
NodeSpecificValidators.validateSet(context);
|
||||
break;
|
||||
|
||||
case 'nodes-base.switch':
|
||||
this.validateSwitchNodeStructure(config, result);
|
||||
break;
|
||||
@@ -397,7 +411,59 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
config: Record<string, any>,
|
||||
result: EnhancedValidationResult
|
||||
): void {
|
||||
// Examples removed - validation provides error messages and fixes instead
|
||||
const url = String(config.url || '');
|
||||
const options = config.options || {};
|
||||
|
||||
// 1. Suggest alwaysOutputData for better error handling (node-level property)
|
||||
// Note: We can't check if it exists (it's node-level, not in parameters),
|
||||
// but we can suggest it as a best practice
|
||||
if (!result.suggestions.some(s => typeof s === 'string' && s.includes('alwaysOutputData'))) {
|
||||
result.suggestions.push(
|
||||
'Consider adding alwaysOutputData: true at node level (not in parameters) for better error handling. ' +
|
||||
'This ensures the node produces output even when HTTP requests fail, allowing downstream error handling.'
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Suggest responseFormat for API endpoints
|
||||
const lowerUrl = url.toLowerCase();
|
||||
const isApiEndpoint =
|
||||
// Subdomain patterns (api.example.com)
|
||||
/^https?:\/\/api\./i.test(url) ||
|
||||
// Path patterns with word boundaries to prevent false positives like "therapist", "restaurant"
|
||||
/\/api[\/\?]|\/api$/i.test(url) ||
|
||||
/\/rest[\/\?]|\/rest$/i.test(url) ||
|
||||
// Known API service domains
|
||||
lowerUrl.includes('supabase.co') ||
|
||||
lowerUrl.includes('firebase') ||
|
||||
lowerUrl.includes('googleapis.com') ||
|
||||
// Versioned API paths (e.g., example.com/v1, example.com/v2)
|
||||
/\.com\/v\d+/i.test(url);
|
||||
|
||||
if (isApiEndpoint && !options.response?.response?.responseFormat) {
|
||||
result.suggestions.push(
|
||||
'API endpoints should explicitly set options.response.response.responseFormat to "json" or "text" ' +
|
||||
'to prevent confusion about response parsing. Example: ' +
|
||||
'{ "options": { "response": { "response": { "responseFormat": "json" } } } }'
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Enhanced URL protocol validation for expressions
|
||||
if (url && url.startsWith('=')) {
|
||||
// Expression-based URL - check for common protocol issues
|
||||
const expressionContent = url.slice(1); // Remove = prefix
|
||||
const lowerExpression = expressionContent.toLowerCase();
|
||||
|
||||
// Check for missing protocol in expression (case-insensitive)
|
||||
if (expressionContent.startsWith('www.') ||
|
||||
(expressionContent.includes('{{') && !lowerExpression.includes('http'))) {
|
||||
result.warnings.push({
|
||||
type: 'invalid_value',
|
||||
property: 'url',
|
||||
message: 'URL expression appears to be missing http:// or https:// protocol',
|
||||
suggestion: 'Include protocol in your expression. Example: ={{ "https://" + $json.domain + ".com" }}'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -462,6 +528,15 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a warning should be filtered out (hardcoded credentials shown only in strict mode)
|
||||
*/
|
||||
private static shouldFilterCredentialWarning(warning: ValidationWarning): boolean {
|
||||
return warning.type === 'security' &&
|
||||
warning.message !== undefined &&
|
||||
warning.message.includes('Hardcoded nodeCredentialType');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply profile-based filtering to validation results
|
||||
*/
|
||||
@@ -474,9 +549,13 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
// Only keep missing required errors
|
||||
result.errors = result.errors.filter(e => e.type === 'missing_required');
|
||||
// Keep ONLY critical warnings (security and deprecated)
|
||||
result.warnings = result.warnings.filter(w =>
|
||||
w.type === 'security' || w.type === 'deprecated'
|
||||
);
|
||||
// But filter out hardcoded credential type warnings (only show in strict mode)
|
||||
result.warnings = result.warnings.filter(w => {
|
||||
if (this.shouldFilterCredentialWarning(w)) {
|
||||
return false;
|
||||
}
|
||||
return w.type === 'security' || w.type === 'deprecated';
|
||||
});
|
||||
result.suggestions = [];
|
||||
break;
|
||||
|
||||
@@ -489,6 +568,10 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
);
|
||||
// Keep security and deprecated warnings, REMOVE property visibility warnings
|
||||
result.warnings = result.warnings.filter(w => {
|
||||
// Filter out hardcoded credential type warnings (only show in strict mode)
|
||||
if (this.shouldFilterCredentialWarning(w)) {
|
||||
return false;
|
||||
}
|
||||
if (w.type === 'security' || w.type === 'deprecated') return true;
|
||||
// FILTER OUT property visibility warnings (too noisy)
|
||||
if (w.type === 'inefficient' && w.message && w.message.includes('not visible')) {
|
||||
@@ -514,6 +597,10 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
// Current behavior - balanced for AI agents
|
||||
// Filter out noise but keep helpful warnings
|
||||
result.warnings = result.warnings.filter(w => {
|
||||
// Filter out hardcoded credential type warnings (only show in strict mode)
|
||||
if (this.shouldFilterCredentialWarning(w)) {
|
||||
return false;
|
||||
}
|
||||
// Keep security and deprecated warnings
|
||||
if (w.type === 'security' || w.type === 'deprecated') return true;
|
||||
// Keep missing common properties
|
||||
@@ -901,4 +988,280 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate special type structures (filter, resourceMapper, assignmentCollection, resourceLocator)
|
||||
*
|
||||
* Integrates TypeStructureService to validate complex property types against their
|
||||
* expected structures. This catches configuration errors for advanced node types.
|
||||
*
|
||||
* @param config - Node configuration to validate
|
||||
* @param properties - Property definitions from node schema
|
||||
* @param result - Validation result to populate with errors/warnings
|
||||
*/
|
||||
private static validateSpecialTypeStructures(
|
||||
config: Record<string, any>,
|
||||
properties: any[],
|
||||
result: EnhancedValidationResult
|
||||
): void {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
// Find property definition
|
||||
const propDef = properties.find(p => p.name === key);
|
||||
if (!propDef) continue;
|
||||
|
||||
// Check if this property uses a special type
|
||||
let structureType: NodePropertyTypes | null = null;
|
||||
|
||||
if (propDef.type === 'filter') {
|
||||
structureType = 'filter';
|
||||
} else if (propDef.type === 'resourceMapper') {
|
||||
structureType = 'resourceMapper';
|
||||
} else if (propDef.type === 'assignmentCollection') {
|
||||
structureType = 'assignmentCollection';
|
||||
} else if (propDef.type === 'resourceLocator') {
|
||||
structureType = 'resourceLocator';
|
||||
}
|
||||
|
||||
if (!structureType) continue;
|
||||
|
||||
// Get structure definition
|
||||
const structure = TypeStructureService.getStructure(structureType);
|
||||
if (!structure) {
|
||||
console.warn(`No structure definition found for type: ${structureType}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate using TypeStructureService for basic type checking
|
||||
const validationResult = TypeStructureService.validateTypeCompatibility(
|
||||
value,
|
||||
structureType
|
||||
);
|
||||
|
||||
// Add errors from structure validation
|
||||
if (!validationResult.valid) {
|
||||
for (const error of validationResult.errors) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: key,
|
||||
message: error,
|
||||
fix: `Ensure ${key} follows the expected structure for ${structureType} type. Example: ${JSON.stringify(structure.example)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add warnings
|
||||
for (const warning of validationResult.warnings) {
|
||||
result.warnings.push({
|
||||
type: 'best_practice',
|
||||
property: key,
|
||||
message: warning
|
||||
});
|
||||
}
|
||||
|
||||
// Perform deep structure validation for complex types
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
this.validateComplexTypeStructure(key, value, structureType, structure, result);
|
||||
}
|
||||
|
||||
// Special handling for filter operation validation
|
||||
if (structureType === 'filter' && value.conditions) {
|
||||
this.validateFilterOperations(value.conditions, key, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep validation for complex type structures
|
||||
*/
|
||||
private static validateComplexTypeStructure(
|
||||
propertyName: string,
|
||||
value: any,
|
||||
type: NodePropertyTypes,
|
||||
structure: any,
|
||||
result: EnhancedValidationResult
|
||||
): void {
|
||||
switch (type) {
|
||||
case 'filter':
|
||||
// Validate filter structure: must have combinator and conditions
|
||||
if (!value.combinator) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.combinator`,
|
||||
message: 'Filter must have a combinator field',
|
||||
fix: 'Add combinator: "and" or combinator: "or" to the filter configuration'
|
||||
});
|
||||
} else if (value.combinator !== 'and' && value.combinator !== 'or') {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.combinator`,
|
||||
message: `Invalid combinator value: ${value.combinator}. Must be "and" or "or"`,
|
||||
fix: 'Set combinator to either "and" or "or"'
|
||||
});
|
||||
}
|
||||
|
||||
if (!value.conditions) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.conditions`,
|
||||
message: 'Filter must have a conditions field',
|
||||
fix: 'Add conditions array to the filter configuration'
|
||||
});
|
||||
} else if (!Array.isArray(value.conditions)) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.conditions`,
|
||||
message: 'Filter conditions must be an array',
|
||||
fix: 'Ensure conditions is an array of condition objects'
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'resourceLocator':
|
||||
// Validate resourceLocator structure: must have mode and value
|
||||
if (!value.mode) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.mode`,
|
||||
message: 'ResourceLocator must have a mode field',
|
||||
fix: 'Add mode: "id", mode: "url", or mode: "list" to the resourceLocator configuration'
|
||||
});
|
||||
} else if (!['id', 'url', 'list', 'name'].includes(value.mode)) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.mode`,
|
||||
message: `Invalid mode value: ${value.mode}. Must be "id", "url", "list", or "name"`,
|
||||
fix: 'Set mode to one of: "id", "url", "list", "name"'
|
||||
});
|
||||
}
|
||||
|
||||
if (!value.hasOwnProperty('value')) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.value`,
|
||||
message: 'ResourceLocator must have a value field',
|
||||
fix: 'Add value field to the resourceLocator configuration'
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'assignmentCollection':
|
||||
// Validate assignmentCollection structure: must have assignments array
|
||||
if (!value.assignments) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.assignments`,
|
||||
message: 'AssignmentCollection must have an assignments field',
|
||||
fix: 'Add assignments array to the assignmentCollection configuration'
|
||||
});
|
||||
} else if (!Array.isArray(value.assignments)) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.assignments`,
|
||||
message: 'AssignmentCollection assignments must be an array',
|
||||
fix: 'Ensure assignments is an array of assignment objects'
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'resourceMapper':
|
||||
// Validate resourceMapper structure: must have mappingMode
|
||||
if (!value.mappingMode) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.mappingMode`,
|
||||
message: 'ResourceMapper must have a mappingMode field',
|
||||
fix: 'Add mappingMode: "defineBelow" or mappingMode: "autoMapInputData"'
|
||||
});
|
||||
} else if (!['defineBelow', 'autoMapInputData'].includes(value.mappingMode)) {
|
||||
result.errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: `${propertyName}.mappingMode`,
|
||||
message: `Invalid mappingMode: ${value.mappingMode}. Must be "defineBelow" or "autoMapInputData"`,
|
||||
fix: 'Set mappingMode to either "defineBelow" or "autoMapInputData"'
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate filter operations match operator types
|
||||
*
|
||||
* Ensures that filter operations are compatible with their operator types.
|
||||
* For example, 'gt' (greater than) is only valid for numbers, not strings.
|
||||
*
|
||||
* @param conditions - Array of filter conditions to validate
|
||||
* @param propertyName - Name of the filter property (for error reporting)
|
||||
* @param result - Validation result to populate with errors
|
||||
*/
|
||||
private static validateFilterOperations(
|
||||
conditions: any,
|
||||
propertyName: string,
|
||||
result: EnhancedValidationResult
|
||||
): void {
|
||||
if (!Array.isArray(conditions)) return;
|
||||
|
||||
// Operation validation rules based on n8n filter type definitions
|
||||
const VALID_OPERATIONS_BY_TYPE: Record<string, string[]> = {
|
||||
string: [
|
||||
'empty', 'notEmpty', 'equals', 'notEquals',
|
||||
'contains', 'notContains', 'startsWith', 'notStartsWith',
|
||||
'endsWith', 'notEndsWith', 'regex', 'notRegex',
|
||||
'exists', 'notExists', 'isNotEmpty' // exists checks field presence, isNotEmpty alias for notEmpty
|
||||
],
|
||||
number: [
|
||||
'empty', 'notEmpty', 'equals', 'notEquals', 'gt', 'lt', 'gte', 'lte',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
],
|
||||
dateTime: [
|
||||
'empty', 'notEmpty', 'equals', 'notEquals', 'after', 'before', 'afterOrEquals', 'beforeOrEquals',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
],
|
||||
boolean: [
|
||||
'empty', 'notEmpty', 'true', 'false', 'equals', 'notEquals',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
],
|
||||
array: [
|
||||
'contains', 'notContains', 'lengthEquals', 'lengthNotEquals',
|
||||
'lengthGt', 'lengthLt', 'lengthGte', 'lengthLte', 'empty', 'notEmpty',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
],
|
||||
object: [
|
||||
'empty', 'notEmpty',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
],
|
||||
any: ['exists', 'notExists', 'isNotEmpty']
|
||||
};
|
||||
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
const condition = conditions[i];
|
||||
if (!condition.operator || typeof condition.operator !== 'object') continue;
|
||||
|
||||
const { type, operation } = condition.operator;
|
||||
if (!type || !operation) continue;
|
||||
|
||||
// Get valid operations for this type
|
||||
const validOperations = VALID_OPERATIONS_BY_TYPE[type];
|
||||
if (!validOperations) {
|
||||
result.warnings.push({
|
||||
type: 'best_practice',
|
||||
property: `${propertyName}.conditions[${i}].operator.type`,
|
||||
message: `Unknown operator type: ${type}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if operation is valid for this type
|
||||
if (!validOperations.includes(operation)) {
|
||||
result.errors.push({
|
||||
type: 'invalid_value',
|
||||
property: `${propertyName}.conditions[${i}].operator.operation`,
|
||||
message: `Operation '${operation}' is not valid for type '${type}'`,
|
||||
fix: `Use one of the valid operations for ${type}: ${validOperations.join(', ')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,8 +207,14 @@ export class ExpressionValidator {
|
||||
expr: string,
|
||||
result: ExpressionValidationResult
|
||||
): void {
|
||||
// Check for missing $ prefix - but exclude cases where $ is already present
|
||||
const missingPrefixPattern = /(?<!\$)\b(json|node|input|items|workflow|execution)\b(?!\s*:)/;
|
||||
// Check for missing $ prefix - but exclude cases where $ is already present OR it's property access (e.g., .json)
|
||||
// The pattern now excludes:
|
||||
// - Immediately preceded by $ (e.g., $json) - handled by (?<!\$)
|
||||
// - Preceded by a dot (e.g., .json in $('Node').item.json.field) - handled by (?<!\.)
|
||||
// - Inside word characters (e.g., myJson) - handled by (?<!\w)
|
||||
// - Inside bracket notation (e.g., ['json']) - handled by (?<![)
|
||||
// - After opening bracket or quote (e.g., "json" or ['json'])
|
||||
const missingPrefixPattern = /(?<![.$\w['])\b(json|node|input|items|workflow|execution)\b(?!\s*[:''])/;
|
||||
if (expr.match(missingPrefixPattern)) {
|
||||
result.warnings.push(
|
||||
'Possible missing $ prefix for variable (e.g., use $json instead of json)'
|
||||
|
||||
@@ -170,10 +170,41 @@ export class N8nApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async activateWorkflow(id: string): Promise<Workflow> {
|
||||
try {
|
||||
const response = await this.client.post(`/workflows/${id}/activate`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deactivateWorkflow(id: string): Promise<Workflow> {
|
||||
try {
|
||||
const response = await this.client.post(`/workflows/${id}/deactivate`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists workflows from n8n instance.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Paginated list of workflows
|
||||
*
|
||||
* @remarks
|
||||
* This method handles two response formats for backwards compatibility:
|
||||
* - Modern (n8n v0.200.0+): {data: Workflow[], nextCursor?: string}
|
||||
* - Legacy (older versions): Workflow[] (wrapped automatically)
|
||||
*
|
||||
* @see https://github.com/czlonkowski/n8n-mcp/issues/349
|
||||
*/
|
||||
async listWorkflows(params: WorkflowListParams = {}): Promise<WorkflowListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/workflows', { params });
|
||||
return response.data;
|
||||
return this.validateListResponse<Workflow>(response.data, 'workflows');
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
@@ -191,10 +222,23 @@ export class N8nApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists executions from n8n instance.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Paginated list of executions
|
||||
*
|
||||
* @remarks
|
||||
* This method handles two response formats for backwards compatibility:
|
||||
* - Modern (n8n v0.200.0+): {data: Execution[], nextCursor?: string}
|
||||
* - Legacy (older versions): Execution[] (wrapped automatically)
|
||||
*
|
||||
* @see https://github.com/czlonkowski/n8n-mcp/issues/349
|
||||
*/
|
||||
async listExecutions(params: ExecutionListParams = {}): Promise<ExecutionListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/executions', { params });
|
||||
return response.data;
|
||||
return this.validateListResponse<Execution>(response.data, 'executions');
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
@@ -261,10 +305,23 @@ export class N8nApiClient {
|
||||
}
|
||||
|
||||
// Credential Management
|
||||
/**
|
||||
* Lists credentials from n8n instance.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Paginated list of credentials
|
||||
*
|
||||
* @remarks
|
||||
* This method handles two response formats for backwards compatibility:
|
||||
* - Modern (n8n v0.200.0+): {data: Credential[], nextCursor?: string}
|
||||
* - Legacy (older versions): Credential[] (wrapped automatically)
|
||||
*
|
||||
* @see https://github.com/czlonkowski/n8n-mcp/issues/349
|
||||
*/
|
||||
async listCredentials(params: CredentialListParams = {}): Promise<CredentialListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/credentials', { params });
|
||||
return response.data;
|
||||
return this.validateListResponse<Credential>(response.data, 'credentials');
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
@@ -306,10 +363,23 @@ export class N8nApiClient {
|
||||
}
|
||||
|
||||
// Tag Management
|
||||
/**
|
||||
* Lists tags from n8n instance.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Paginated list of tags
|
||||
*
|
||||
* @remarks
|
||||
* This method handles two response formats for backwards compatibility:
|
||||
* - Modern (n8n v0.200.0+): {data: Tag[], nextCursor?: string}
|
||||
* - Legacy (older versions): Tag[] (wrapped automatically)
|
||||
*
|
||||
* @see https://github.com/czlonkowski/n8n-mcp/issues/349
|
||||
*/
|
||||
async listTags(params: TagListParams = {}): Promise<TagListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/tags', { params });
|
||||
return response.data;
|
||||
return this.validateListResponse<Tag>(response.data, 'tags');
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
@@ -412,4 +482,49 @@ export class N8nApiClient {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes n8n API list responses.
|
||||
* Handles both modern format {data: [], nextCursor?: string} and legacy array format.
|
||||
*
|
||||
* @param responseData - Raw response data from n8n API
|
||||
* @param resourceType - Resource type for error messages (e.g., 'workflows', 'executions')
|
||||
* @returns Normalized response in modern format
|
||||
* @throws Error if response structure is invalid
|
||||
*/
|
||||
private validateListResponse<T>(
|
||||
responseData: any,
|
||||
resourceType: string
|
||||
): { data: T[]; nextCursor?: string | null } {
|
||||
// Validate response structure
|
||||
if (!responseData || typeof responseData !== 'object') {
|
||||
throw new Error(`Invalid response from n8n API for ${resourceType}: response is not an object`);
|
||||
}
|
||||
|
||||
// Handle legacy case where API returns array directly (older n8n versions)
|
||||
if (Array.isArray(responseData)) {
|
||||
logger.warn(
|
||||
`n8n API returned array directly instead of {data, nextCursor} object for ${resourceType}. ` +
|
||||
'Wrapping in expected format for backwards compatibility.'
|
||||
);
|
||||
return {
|
||||
data: responseData,
|
||||
nextCursor: null
|
||||
};
|
||||
}
|
||||
|
||||
// Validate expected format {data: [], nextCursor?: string}
|
||||
if (!Array.isArray(responseData.data)) {
|
||||
const keys = Object.keys(responseData).slice(0, 5);
|
||||
const keysPreview = keys.length < Object.keys(responseData).length
|
||||
? `${keys.join(', ')}...`
|
||||
: keys.join(', ');
|
||||
throw new Error(
|
||||
`Invalid response from n8n API for ${resourceType}: expected {data: [], nextCursor?: string}, ` +
|
||||
`got object with keys: [${keysPreview}]`
|
||||
);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { WorkflowNode, WorkflowConnection, Workflow } from '../types/n8n-api';
|
||||
import { isTriggerNode, isActivatableTrigger } from '../utils/node-type-utils';
|
||||
import { isNonExecutableNode } from '../utils/node-classification';
|
||||
|
||||
// Zod schemas for n8n API validation
|
||||
|
||||
@@ -22,17 +24,31 @@ export const workflowNodeSchema = z.object({
|
||||
executeOnce: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Connection array schema used by all connection types
|
||||
const connectionArraySchema = z.array(
|
||||
z.array(
|
||||
z.object({
|
||||
node: z.string(),
|
||||
type: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Workflow connection schema supporting all connection types.
|
||||
* Note: 'main' is optional because AI nodes exclusively use AI-specific
|
||||
* connection types (ai_languageModel, ai_memory, etc.) without main connections.
|
||||
*/
|
||||
export const workflowConnectionSchema = z.record(
|
||||
z.object({
|
||||
main: z.array(
|
||||
z.array(
|
||||
z.object({
|
||||
node: z.string(),
|
||||
type: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
)
|
||||
),
|
||||
main: connectionArraySchema.optional(),
|
||||
error: connectionArraySchema.optional(),
|
||||
ai_tool: connectionArraySchema.optional(),
|
||||
ai_languageModel: connectionArraySchema.optional(),
|
||||
ai_memory: connectionArraySchema.optional(),
|
||||
ai_embedding: connectionArraySchema.optional(),
|
||||
ai_vectorStore: connectionArraySchema.optional(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -87,7 +103,8 @@ export function cleanWorkflowForCreate(workflow: Partial<Workflow>): Partial<Wor
|
||||
} = workflow;
|
||||
|
||||
// Ensure settings are present with defaults
|
||||
if (!cleanedWorkflow.settings) {
|
||||
// Treat empty settings object {} the same as missing settings
|
||||
if (!cleanedWorkflow.settings || Object.keys(cleanedWorkflow.settings).length === 0) {
|
||||
cleanedWorkflow.settings = defaultWorkflowSettings;
|
||||
}
|
||||
|
||||
@@ -117,11 +134,13 @@ export function cleanWorkflowForUpdate(workflow: Workflow): Partial<Workflow> {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
versionId,
|
||||
versionCounter, // Added: n8n 1.118.1+ returns this but rejects it in updates
|
||||
meta,
|
||||
staticData,
|
||||
// Remove fields that cause API errors
|
||||
pinData,
|
||||
tags,
|
||||
description, // Issue #431: n8n returns this field but rejects it in updates
|
||||
// Remove additional fields that n8n API doesn't accept
|
||||
isArchived,
|
||||
usedCredentials,
|
||||
@@ -138,16 +157,17 @@ export function cleanWorkflowForUpdate(workflow: Workflow): Partial<Workflow> {
|
||||
//
|
||||
// PROBLEM:
|
||||
// - Some versions reject updates with settings properties (community forum reports)
|
||||
// - Cloud versions REQUIRE settings property to be present (n8n.estyl.team)
|
||||
// - Properties like callerPolicy cause "additional properties" errors
|
||||
// - Empty settings objects {} cause "additional properties" validation errors (Issue #431)
|
||||
//
|
||||
// SOLUTION:
|
||||
// - Filter settings to only include whitelisted properties (OpenAPI spec)
|
||||
// - If no settings provided, use empty object {} for safety
|
||||
// - Empty object satisfies "required property" validation (cloud API)
|
||||
// - If no settings after filtering, omit the property entirely (n8n API rejects empty objects)
|
||||
// - Omitting the property prevents "additional properties" validation errors
|
||||
// - Whitelisted properties prevent "additional properties" errors
|
||||
//
|
||||
// References:
|
||||
// - Issue #431: Empty settings validation error
|
||||
// - https://community.n8n.io/t/api-workflow-update-endpoint-doesnt-support-setting-callerpolicy/161916
|
||||
// - OpenAPI spec: workflowSettings schema
|
||||
// - Tested on n8n.estyl.team (cloud) and localhost (self-hosted)
|
||||
@@ -172,10 +192,19 @@ export function cleanWorkflowForUpdate(workflow: Workflow): Partial<Workflow> {
|
||||
filteredSettings[key] = (cleanedWorkflow.settings as any)[key];
|
||||
}
|
||||
}
|
||||
cleanedWorkflow.settings = filteredSettings;
|
||||
|
||||
// n8n API requires settings to be present but rejects empty settings objects.
|
||||
// If no valid properties remain after filtering, include minimal default settings.
|
||||
if (Object.keys(filteredSettings).length > 0) {
|
||||
cleanedWorkflow.settings = filteredSettings;
|
||||
} else {
|
||||
// Provide minimal valid settings (executionOrder v1 is the modern default)
|
||||
cleanedWorkflow.settings = { executionOrder: 'v1' as const };
|
||||
}
|
||||
} else {
|
||||
// No settings provided - use empty object for safety
|
||||
cleanedWorkflow.settings = {};
|
||||
// No settings provided - include minimal default settings
|
||||
// n8n API requires settings in workflow updates (v1 is the modern default)
|
||||
cleanedWorkflow.settings = { executionOrder: 'v1' as const };
|
||||
}
|
||||
|
||||
return cleanedWorkflow;
|
||||
@@ -194,6 +223,14 @@ export function validateWorkflowStructure(workflow: Partial<Workflow>): string[]
|
||||
errors.push('Workflow must have at least one node');
|
||||
}
|
||||
|
||||
// Check if workflow has only non-executable nodes (sticky notes)
|
||||
if (workflow.nodes && workflow.nodes.length > 0) {
|
||||
const hasExecutableNodes = workflow.nodes.some(node => !isNonExecutableNode(node.type));
|
||||
if (!hasExecutableNodes) {
|
||||
errors.push('Workflow must have at least one executable node. Sticky notes alone cannot form a valid workflow.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!workflow.connections) {
|
||||
errors.push('Workflow connections are required');
|
||||
}
|
||||
@@ -201,20 +238,71 @@ export function validateWorkflowStructure(workflow: Partial<Workflow>): string[]
|
||||
// Check for minimum viable workflow
|
||||
if (workflow.nodes && workflow.nodes.length === 1) {
|
||||
const singleNode = workflow.nodes[0];
|
||||
const isWebhookOnly = singleNode.type === 'n8n-nodes-base.webhook' ||
|
||||
const isWebhookOnly = singleNode.type === 'n8n-nodes-base.webhook' ||
|
||||
singleNode.type === 'n8n-nodes-base.webhookTrigger';
|
||||
|
||||
|
||||
if (!isWebhookOnly) {
|
||||
errors.push('Single-node workflows are only valid for webhooks. Add at least one more node and connect them. Example: Manual Trigger → Set node');
|
||||
errors.push(`Single non-webhook node workflow is invalid. Current node: "${singleNode.name}" (${singleNode.type}). Add another node using: {type: 'addNode', node: {name: 'Process Data', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [450, 300], parameters: {}}}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for empty connections in multi-node workflows
|
||||
// Check for disconnected nodes in multi-node workflows
|
||||
if (workflow.nodes && workflow.nodes.length > 1 && workflow.connections) {
|
||||
// Filter out non-executable nodes (sticky notes) when counting nodes
|
||||
const executableNodes = workflow.nodes.filter(node => !isNonExecutableNode(node.type));
|
||||
const connectionCount = Object.keys(workflow.connections).length;
|
||||
|
||||
if (connectionCount === 0) {
|
||||
errors.push('Multi-node workflow has empty connections. Connect nodes like this: connections: { "Node1 Name": { "main": [[{ "node": "Node2 Name", "type": "main", "index": 0 }]] } }');
|
||||
|
||||
// First check: workflow has no connections at all (only check if there are multiple executable nodes)
|
||||
if (connectionCount === 0 && executableNodes.length > 1) {
|
||||
const nodeNames = executableNodes.slice(0, 2).map(n => n.name);
|
||||
errors.push(`Multi-node workflow has no connections between nodes. Add a connection using: {type: 'addConnection', source: '${nodeNames[0]}', target: '${nodeNames[1]}', sourcePort: 'main', targetPort: 'main'}`);
|
||||
} else if (connectionCount > 0 || executableNodes.length > 1) {
|
||||
// Second check: detect disconnected nodes (nodes with no incoming or outgoing connections)
|
||||
const connectedNodes = new Set<string>();
|
||||
|
||||
// Collect all nodes that appear in connections (as source or target)
|
||||
Object.entries(workflow.connections).forEach(([sourceName, connection]) => {
|
||||
connectedNodes.add(sourceName); // Node has outgoing connection
|
||||
|
||||
if (connection.main && Array.isArray(connection.main)) {
|
||||
connection.main.forEach((outputs) => {
|
||||
if (Array.isArray(outputs)) {
|
||||
outputs.forEach((target) => {
|
||||
connectedNodes.add(target.node); // Node has incoming connection
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Find disconnected nodes (excluding non-executable nodes and triggers)
|
||||
// Non-executable nodes (sticky notes) are UI-only and don't need connections
|
||||
// Trigger nodes only need outgoing connections
|
||||
const disconnectedNodes = workflow.nodes.filter(node => {
|
||||
// Skip non-executable nodes (sticky notes, etc.) - they're UI-only annotations
|
||||
if (isNonExecutableNode(node.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isConnected = connectedNodes.has(node.name);
|
||||
const isNodeTrigger = isTriggerNode(node.type);
|
||||
|
||||
// Trigger nodes only need outgoing connections
|
||||
if (isNodeTrigger) {
|
||||
return !workflow.connections?.[node.name]; // Disconnected if no outgoing connections
|
||||
}
|
||||
|
||||
// Regular nodes need at least one connection (incoming or outgoing)
|
||||
return !isConnected;
|
||||
});
|
||||
|
||||
if (disconnectedNodes.length > 0) {
|
||||
const disconnectedList = disconnectedNodes.map(n => `"${n.name}" (${n.type})`).join(', ');
|
||||
const firstDisconnected = disconnectedNodes[0];
|
||||
const suggestedSource = workflow.nodes.find(n => connectedNodes.has(n.name))?.name || workflow.nodes[0].name;
|
||||
|
||||
errors.push(`Disconnected nodes detected: ${disconnectedList}. Each node must have at least one connection. Add a connection: {type: 'addConnection', source: '${suggestedSource}', target: '${firstDisconnected.name}', sourcePort: 'main', targetPort: 'main'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +324,16 @@ export function validateWorkflowStructure(workflow: Partial<Workflow>): string[]
|
||||
});
|
||||
}
|
||||
|
||||
// Validate filter-based nodes (IF v2.2+, Switch v3.2+) have complete metadata
|
||||
if (workflow.nodes) {
|
||||
workflow.nodes.forEach((node, index) => {
|
||||
const filterErrors = validateFilterBasedNodeMetadata(node);
|
||||
if (filterErrors.length > 0) {
|
||||
errors.push(...filterErrors.map(err => `Node "${node.name}" (index ${index}): ${err}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate connections
|
||||
if (workflow.connections) {
|
||||
try {
|
||||
@@ -245,12 +343,89 @@ export function validateWorkflowStructure(workflow: Partial<Workflow>): string[]
|
||||
}
|
||||
}
|
||||
|
||||
// Validate active workflows have activatable triggers
|
||||
// Issue #351: executeWorkflowTrigger cannot activate a workflow
|
||||
// It can only be invoked by other workflows
|
||||
if ((workflow as any).active === true && workflow.nodes && workflow.nodes.length > 0) {
|
||||
const activatableTriggers = workflow.nodes.filter(node =>
|
||||
!node.disabled && isActivatableTrigger(node.type)
|
||||
);
|
||||
|
||||
const executeWorkflowTriggers = workflow.nodes.filter(node =>
|
||||
!node.disabled && node.type.toLowerCase().includes('executeworkflow')
|
||||
);
|
||||
|
||||
if (activatableTriggers.length === 0 && executeWorkflowTriggers.length > 0) {
|
||||
// Workflow is active but only has executeWorkflowTrigger nodes
|
||||
const triggerNames = executeWorkflowTriggers.map(n => n.name).join(', ');
|
||||
errors.push(
|
||||
`Cannot activate workflow with only Execute Workflow Trigger nodes (${triggerNames}). ` +
|
||||
'Execute Workflow Trigger can only be invoked by other workflows, not activated. ' +
|
||||
'Either deactivate the workflow or add a webhook/schedule/polling trigger.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Switch and IF node connection structures match their rules
|
||||
if (workflow.nodes && workflow.connections) {
|
||||
const switchNodes = workflow.nodes.filter(n => {
|
||||
if (n.type !== 'n8n-nodes-base.switch') return false;
|
||||
const mode = (n.parameters as any)?.mode;
|
||||
return !mode || mode === 'rules'; // Default mode is 'rules'
|
||||
});
|
||||
|
||||
for (const switchNode of switchNodes) {
|
||||
const params = switchNode.parameters as any;
|
||||
const rules = params?.rules?.rules || [];
|
||||
const nodeConnections = workflow.connections[switchNode.name];
|
||||
|
||||
if (rules.length > 0 && nodeConnections?.main) {
|
||||
const outputBranches = nodeConnections.main.length;
|
||||
|
||||
// Switch nodes in "rules" mode need output branches matching rules count
|
||||
if (outputBranches !== rules.length) {
|
||||
const ruleNames = rules.map((r: any, i: number) =>
|
||||
r.outputKey ? `"${r.outputKey}" (index ${i})` : `Rule ${i}`
|
||||
).join(', ');
|
||||
|
||||
errors.push(
|
||||
`Switch node "${switchNode.name}" has ${rules.length} rules [${ruleNames}] ` +
|
||||
`but only ${outputBranches} output branch${outputBranches !== 1 ? 'es' : ''} in connections. ` +
|
||||
`Each rule needs its own output branch. When connecting to Switch outputs, specify sourceIndex: ` +
|
||||
rules.map((_: any, i: number) => i).join(', ') +
|
||||
` (or use case parameter for clarity).`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for empty output branches (except trailing ones)
|
||||
const nonEmptyBranches = nodeConnections.main.filter((branch: any[]) => branch.length > 0).length;
|
||||
if (nonEmptyBranches < rules.length) {
|
||||
const emptyIndices = nodeConnections.main
|
||||
.map((branch: any[], i: number) => branch.length === 0 ? i : -1)
|
||||
.filter((i: number) => i !== -1 && i < rules.length);
|
||||
|
||||
if (emptyIndices.length > 0) {
|
||||
const ruleInfo = emptyIndices.map((i: number) => {
|
||||
const rule = rules[i];
|
||||
return rule.outputKey ? `"${rule.outputKey}" (index ${i})` : `Rule ${i}`;
|
||||
}).join(', ');
|
||||
|
||||
errors.push(
|
||||
`Switch node "${switchNode.name}" has unconnected output${emptyIndices.length !== 1 ? 's' : ''}: ${ruleInfo}. ` +
|
||||
`Add connection${emptyIndices.length !== 1 ? 's' : ''} using sourceIndex: ${emptyIndices.join(' or ')}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all connection references exist and use node NAMES (not IDs)
|
||||
if (workflow.nodes && workflow.connections) {
|
||||
const nodeNames = new Set(workflow.nodes.map(node => node.name));
|
||||
const nodeIds = new Set(workflow.nodes.map(node => node.id));
|
||||
const nodeIdToName = new Map(workflow.nodes.map(node => [node.id, node.name]));
|
||||
|
||||
|
||||
Object.entries(workflow.connections).forEach(([sourceName, connection]) => {
|
||||
// Check if source exists by name (correct)
|
||||
if (!nodeNames.has(sourceName)) {
|
||||
@@ -289,12 +464,177 @@ export function validateWorkflowStructure(workflow: Partial<Workflow>): string[]
|
||||
|
||||
// Check if workflow has webhook trigger
|
||||
export function hasWebhookTrigger(workflow: Workflow): boolean {
|
||||
return workflow.nodes.some(node =>
|
||||
node.type === 'n8n-nodes-base.webhook' ||
|
||||
return workflow.nodes.some(node =>
|
||||
node.type === 'n8n-nodes-base.webhook' ||
|
||||
node.type === 'n8n-nodes-base.webhookTrigger'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate filter-based node metadata (IF v2.2+, Switch v3.2+)
|
||||
* Returns array of error messages
|
||||
*/
|
||||
export function validateFilterBasedNodeMetadata(node: WorkflowNode): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check if node is filter-based
|
||||
const isIFNode = node.type === 'n8n-nodes-base.if' && node.typeVersion >= 2.2;
|
||||
const isSwitchNode = node.type === 'n8n-nodes-base.switch' && node.typeVersion >= 3.2;
|
||||
|
||||
if (!isIFNode && !isSwitchNode) {
|
||||
return errors; // Not a filter-based node
|
||||
}
|
||||
|
||||
// Validate IF node
|
||||
if (isIFNode) {
|
||||
const conditions = (node.parameters.conditions as any);
|
||||
|
||||
// Check conditions.options exists
|
||||
if (!conditions?.options) {
|
||||
errors.push(
|
||||
'Missing required "conditions.options". ' +
|
||||
'IF v2.2+ requires: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
|
||||
);
|
||||
} else {
|
||||
// Validate required fields
|
||||
const requiredFields = {
|
||||
version: 2,
|
||||
leftValue: '',
|
||||
caseSensitive: 'boolean',
|
||||
typeValidation: 'strict'
|
||||
};
|
||||
|
||||
for (const [field, expectedValue] of Object.entries(requiredFields)) {
|
||||
if (!(field in conditions.options)) {
|
||||
errors.push(
|
||||
`Missing required field "conditions.options.${field}". ` +
|
||||
`Expected value: ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate operators in conditions
|
||||
if (conditions?.conditions && Array.isArray(conditions.conditions)) {
|
||||
conditions.conditions.forEach((condition: any, i: number) => {
|
||||
const operatorErrors = validateOperatorStructure(condition.operator, `conditions.conditions[${i}].operator`);
|
||||
errors.push(...operatorErrors);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Switch node
|
||||
if (isSwitchNode) {
|
||||
const rules = (node.parameters.rules as any);
|
||||
|
||||
if (rules?.rules && Array.isArray(rules.rules)) {
|
||||
rules.rules.forEach((rule: any, ruleIndex: number) => {
|
||||
// Check rule.conditions.options
|
||||
if (!rule.conditions?.options) {
|
||||
errors.push(
|
||||
`Missing required "rules.rules[${ruleIndex}].conditions.options". ` +
|
||||
'Switch v3.2+ requires: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
|
||||
);
|
||||
} else {
|
||||
// Validate required fields
|
||||
const requiredFields = {
|
||||
version: 2,
|
||||
leftValue: '',
|
||||
caseSensitive: 'boolean',
|
||||
typeValidation: 'strict'
|
||||
};
|
||||
|
||||
for (const [field, expectedValue] of Object.entries(requiredFields)) {
|
||||
if (!(field in rule.conditions.options)) {
|
||||
errors.push(
|
||||
`Missing required field "rules.rules[${ruleIndex}].conditions.options.${field}". ` +
|
||||
`Expected value: ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate operators in rule conditions
|
||||
if (rule.conditions?.conditions && Array.isArray(rule.conditions.conditions)) {
|
||||
rule.conditions.conditions.forEach((condition: any, condIndex: number) => {
|
||||
const operatorErrors = validateOperatorStructure(
|
||||
condition.operator,
|
||||
`rules.rules[${ruleIndex}].conditions.conditions[${condIndex}].operator`
|
||||
);
|
||||
errors.push(...operatorErrors);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate operator structure
|
||||
* Ensures operator has correct format: {type, operation, singleValue?}
|
||||
*/
|
||||
export function validateOperatorStructure(operator: any, path: string): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!operator || typeof operator !== 'object') {
|
||||
errors.push(`${path}: operator is missing or not an object`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Check required field: type (data type, not operation name)
|
||||
if (!operator.type) {
|
||||
errors.push(
|
||||
`${path}: missing required field "type". ` +
|
||||
'Must be a data type: "string", "number", "boolean", "dateTime", "array", or "object"'
|
||||
);
|
||||
} else {
|
||||
const validTypes = ['string', 'number', 'boolean', 'dateTime', 'array', 'object'];
|
||||
if (!validTypes.includes(operator.type)) {
|
||||
errors.push(
|
||||
`${path}: invalid type "${operator.type}". ` +
|
||||
`Type must be a data type (${validTypes.join(', ')}), not an operation name. ` +
|
||||
'Did you mean to use the "operation" field?'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check required field: operation
|
||||
if (!operator.operation) {
|
||||
errors.push(
|
||||
`${path}: missing required field "operation". ` +
|
||||
'Operation specifies the comparison type (e.g., "equals", "contains", "isNotEmpty")'
|
||||
);
|
||||
}
|
||||
|
||||
// Check singleValue based on operator type
|
||||
if (operator.operation) {
|
||||
const unaryOperators = ['isEmpty', 'isNotEmpty', 'true', 'false', 'isNumeric'];
|
||||
const isUnary = unaryOperators.includes(operator.operation);
|
||||
|
||||
if (isUnary) {
|
||||
// Unary operators MUST have singleValue: true
|
||||
if (operator.singleValue !== true) {
|
||||
errors.push(
|
||||
`${path}: unary operator "${operator.operation}" requires "singleValue: true". ` +
|
||||
'Unary operators do not use rightValue.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Binary operators should NOT have singleValue: true
|
||||
if (operator.singleValue === true) {
|
||||
errors.push(
|
||||
`${path}: binary operator "${operator.operation}" should not have "singleValue: true". ` +
|
||||
'Only unary operators (isEmpty, isNotEmpty, true, false, isNumeric) need this property.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Get webhook URL from workflow
|
||||
export function getWebhookUrl(workflow: Workflow): string | null {
|
||||
const webhookNode = workflow.nodes.find(node =>
|
||||
|
||||
410
src/services/node-migration-service.ts
Normal file
410
src/services/node-migration-service.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Node Migration Service
|
||||
*
|
||||
* Handles smart auto-migration of node configurations during version upgrades.
|
||||
* Applies migration strategies from the breaking changes registry and detectors.
|
||||
*
|
||||
* Migration strategies:
|
||||
* - add_property: Add new required/optional properties with defaults
|
||||
* - remove_property: Remove deprecated properties
|
||||
* - rename_property: Rename properties that changed names
|
||||
* - set_default: Set default values for properties
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { BreakingChangeDetector, DetectedChange } from './breaking-change-detector';
|
||||
import { NodeVersionService } from './node-version-service';
|
||||
|
||||
export interface MigrationResult {
|
||||
success: boolean;
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
appliedMigrations: AppliedMigration[];
|
||||
remainingIssues: string[];
|
||||
confidence: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
updatedNode: any; // The migrated node configuration
|
||||
}
|
||||
|
||||
export interface AppliedMigration {
|
||||
propertyName: string;
|
||||
action: string;
|
||||
oldValue?: any;
|
||||
newValue?: any;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class NodeMigrationService {
|
||||
constructor(
|
||||
private versionService: NodeVersionService,
|
||||
private breakingChangeDetector: BreakingChangeDetector
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Migrate a node from its current version to a target version
|
||||
*/
|
||||
async migrateNode(
|
||||
node: any,
|
||||
fromVersion: string,
|
||||
toVersion: string
|
||||
): Promise<MigrationResult> {
|
||||
const nodeId = node.id || 'unknown';
|
||||
const nodeName = node.name || 'Unknown Node';
|
||||
const nodeType = node.type;
|
||||
|
||||
// Analyze the version upgrade
|
||||
const analysis = await this.breakingChangeDetector.analyzeVersionUpgrade(
|
||||
nodeType,
|
||||
fromVersion,
|
||||
toVersion
|
||||
);
|
||||
|
||||
// Start with a copy of the node
|
||||
const migratedNode = JSON.parse(JSON.stringify(node));
|
||||
|
||||
// Apply the version update
|
||||
migratedNode.typeVersion = this.parseVersion(toVersion);
|
||||
|
||||
const appliedMigrations: AppliedMigration[] = [];
|
||||
const remainingIssues: string[] = [];
|
||||
|
||||
// Apply auto-migratable changes
|
||||
for (const change of analysis.changes.filter(c => c.autoMigratable)) {
|
||||
const migration = this.applyMigration(migratedNode, change);
|
||||
|
||||
if (migration) {
|
||||
appliedMigrations.push(migration);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect remaining manual issues
|
||||
for (const change of analysis.changes.filter(c => !c.autoMigratable)) {
|
||||
remainingIssues.push(
|
||||
`Manual action required for "${change.propertyName}": ${change.migrationHint}`
|
||||
);
|
||||
}
|
||||
|
||||
// Determine confidence based on remaining issues
|
||||
let confidence: 'HIGH' | 'MEDIUM' | 'LOW' = 'HIGH';
|
||||
|
||||
if (remainingIssues.length > 0) {
|
||||
confidence = remainingIssues.length > 3 ? 'LOW' : 'MEDIUM';
|
||||
}
|
||||
|
||||
return {
|
||||
success: remainingIssues.length === 0,
|
||||
nodeId,
|
||||
nodeName,
|
||||
fromVersion,
|
||||
toVersion,
|
||||
appliedMigrations,
|
||||
remainingIssues,
|
||||
confidence,
|
||||
updatedNode: migratedNode
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single migration change to a node
|
||||
*/
|
||||
private applyMigration(node: any, change: DetectedChange): AppliedMigration | null {
|
||||
if (!change.migrationStrategy) return null;
|
||||
|
||||
const { type, defaultValue, sourceProperty, targetProperty } = change.migrationStrategy;
|
||||
|
||||
switch (type) {
|
||||
case 'add_property':
|
||||
return this.addProperty(node, change.propertyName, defaultValue, change);
|
||||
|
||||
case 'remove_property':
|
||||
return this.removeProperty(node, change.propertyName, change);
|
||||
|
||||
case 'rename_property':
|
||||
return this.renameProperty(node, sourceProperty!, targetProperty!, change);
|
||||
|
||||
case 'set_default':
|
||||
return this.setDefault(node, change.propertyName, defaultValue, change);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new property to the node configuration
|
||||
*/
|
||||
private addProperty(
|
||||
node: any,
|
||||
propertyPath: string,
|
||||
defaultValue: any,
|
||||
change: DetectedChange
|
||||
): AppliedMigration {
|
||||
const value = this.resolveDefaultValue(propertyPath, defaultValue, node);
|
||||
|
||||
// Handle nested property paths (e.g., "parameters.inputFieldMapping")
|
||||
const parts = propertyPath.split('.');
|
||||
let target = node;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!target[part]) {
|
||||
target[part] = {};
|
||||
}
|
||||
target = target[part];
|
||||
}
|
||||
|
||||
const finalKey = parts[parts.length - 1];
|
||||
target[finalKey] = value;
|
||||
|
||||
return {
|
||||
propertyName: propertyPath,
|
||||
action: 'Added property',
|
||||
newValue: value,
|
||||
description: `Added "${propertyPath}" with default value`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a deprecated property from the node configuration
|
||||
*/
|
||||
private removeProperty(
|
||||
node: any,
|
||||
propertyPath: string,
|
||||
change: DetectedChange
|
||||
): AppliedMigration | null {
|
||||
const parts = propertyPath.split('.');
|
||||
let target = node;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!target[part]) return null; // Property doesn't exist
|
||||
target = target[part];
|
||||
}
|
||||
|
||||
const finalKey = parts[parts.length - 1];
|
||||
const oldValue = target[finalKey];
|
||||
|
||||
if (oldValue !== undefined) {
|
||||
delete target[finalKey];
|
||||
|
||||
return {
|
||||
propertyName: propertyPath,
|
||||
action: 'Removed property',
|
||||
oldValue,
|
||||
description: `Removed deprecated property "${propertyPath}"`
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a property (move value from old name to new name)
|
||||
*/
|
||||
private renameProperty(
|
||||
node: any,
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
change: DetectedChange
|
||||
): AppliedMigration | null {
|
||||
// Get old value
|
||||
const sourceParts = sourcePath.split('.');
|
||||
let sourceTarget = node;
|
||||
|
||||
for (let i = 0; i < sourceParts.length - 1; i++) {
|
||||
if (!sourceTarget[sourceParts[i]]) return null;
|
||||
sourceTarget = sourceTarget[sourceParts[i]];
|
||||
}
|
||||
|
||||
const sourceKey = sourceParts[sourceParts.length - 1];
|
||||
const oldValue = sourceTarget[sourceKey];
|
||||
|
||||
if (oldValue === undefined) return null; // Source doesn't exist
|
||||
|
||||
// Set new value
|
||||
const targetParts = targetPath.split('.');
|
||||
let targetTarget = node;
|
||||
|
||||
for (let i = 0; i < targetParts.length - 1; i++) {
|
||||
if (!targetTarget[targetParts[i]]) {
|
||||
targetTarget[targetParts[i]] = {};
|
||||
}
|
||||
targetTarget = targetTarget[targetParts[i]];
|
||||
}
|
||||
|
||||
const targetKey = targetParts[targetParts.length - 1];
|
||||
targetTarget[targetKey] = oldValue;
|
||||
|
||||
// Remove old value
|
||||
delete sourceTarget[sourceKey];
|
||||
|
||||
return {
|
||||
propertyName: targetPath,
|
||||
action: 'Renamed property',
|
||||
oldValue: `${sourcePath}: ${JSON.stringify(oldValue)}`,
|
||||
newValue: `${targetPath}: ${JSON.stringify(oldValue)}`,
|
||||
description: `Renamed "${sourcePath}" to "${targetPath}"`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a default value for a property
|
||||
*/
|
||||
private setDefault(
|
||||
node: any,
|
||||
propertyPath: string,
|
||||
defaultValue: any,
|
||||
change: DetectedChange
|
||||
): AppliedMigration | null {
|
||||
const parts = propertyPath.split('.');
|
||||
let target = node;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!target[parts[i]]) {
|
||||
target[parts[i]] = {};
|
||||
}
|
||||
target = target[parts[i]];
|
||||
}
|
||||
|
||||
const finalKey = parts[parts.length - 1];
|
||||
|
||||
// Only set if not already defined
|
||||
if (target[finalKey] === undefined) {
|
||||
const value = this.resolveDefaultValue(propertyPath, defaultValue, node);
|
||||
target[finalKey] = value;
|
||||
|
||||
return {
|
||||
propertyName: propertyPath,
|
||||
action: 'Set default value',
|
||||
newValue: value,
|
||||
description: `Set default value for "${propertyPath}"`
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve default value with special handling for certain property types
|
||||
*/
|
||||
private resolveDefaultValue(propertyPath: string, defaultValue: any, node: any): any {
|
||||
// Special case: webhookId needs a UUID
|
||||
if (propertyPath === 'webhookId' || propertyPath.endsWith('.webhookId')) {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
// Special case: webhook path needs a unique value
|
||||
if (propertyPath === 'path' || propertyPath.endsWith('.path')) {
|
||||
if (node.type === 'n8n-nodes-base.webhook') {
|
||||
return `/webhook-${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Return provided default or null
|
||||
return defaultValue !== null && defaultValue !== undefined ? defaultValue : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version string to number (for typeVersion field)
|
||||
*/
|
||||
private parseVersion(version: string): number {
|
||||
const parts = version.split('.').map(Number);
|
||||
|
||||
// Handle versions like "1.1" -> 1.1, "2.0" -> 2
|
||||
if (parts.length === 1) return parts[0];
|
||||
if (parts.length === 2) return parts[0] + parts[1] / 10;
|
||||
|
||||
// For more complex versions, just use first number
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a migrated node is valid
|
||||
*/
|
||||
async validateMigratedNode(node: any, nodeType: string): Promise<{
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Basic validation
|
||||
if (!node.typeVersion) {
|
||||
errors.push('Missing typeVersion after migration');
|
||||
}
|
||||
|
||||
if (!node.parameters) {
|
||||
errors.push('Missing parameters object');
|
||||
}
|
||||
|
||||
// Check for common issues
|
||||
if (nodeType === 'n8n-nodes-base.webhook') {
|
||||
if (!node.parameters?.path) {
|
||||
errors.push('Webhook node missing required "path" parameter');
|
||||
}
|
||||
if (node.typeVersion >= 2.1 && !node.webhookId) {
|
||||
warnings.push('Webhook v2.1+ typically requires webhookId');
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeType === 'n8n-nodes-base.executeWorkflow') {
|
||||
if (node.typeVersion >= 1.1 && !node.parameters?.inputFieldMapping) {
|
||||
errors.push('Execute Workflow v1.1+ requires inputFieldMapping');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch migrate multiple nodes in a workflow
|
||||
*/
|
||||
async migrateWorkflowNodes(
|
||||
workflow: any,
|
||||
targetVersions: Record<string, string> // nodeId -> targetVersion
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
results: MigrationResult[];
|
||||
overallConfidence: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
}> {
|
||||
const results: MigrationResult[] = [];
|
||||
|
||||
for (const node of workflow.nodes || []) {
|
||||
const targetVersion = targetVersions[node.id];
|
||||
|
||||
if (targetVersion && node.typeVersion) {
|
||||
const currentVersion = node.typeVersion.toString();
|
||||
|
||||
const result = await this.migrateNode(node, currentVersion, targetVersion);
|
||||
results.push(result);
|
||||
|
||||
// Update node in place
|
||||
Object.assign(node, result.updatedNode);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall confidence
|
||||
const confidences = results.map(r => r.confidence);
|
||||
let overallConfidence: 'HIGH' | 'MEDIUM' | 'LOW' = 'HIGH';
|
||||
|
||||
if (confidences.includes('LOW')) {
|
||||
overallConfidence = 'LOW';
|
||||
} else if (confidences.includes('MEDIUM')) {
|
||||
overallConfidence = 'MEDIUM';
|
||||
}
|
||||
|
||||
const success = results.every(r => r.success);
|
||||
|
||||
return {
|
||||
success,
|
||||
results,
|
||||
overallConfidence
|
||||
};
|
||||
}
|
||||
}
|
||||
361
src/services/node-sanitizer.ts
Normal file
361
src/services/node-sanitizer.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Node Sanitizer Service
|
||||
*
|
||||
* Ensures nodes have complete metadata required by n8n UI.
|
||||
* Based on n8n AI Workflow Builder patterns:
|
||||
* - Merges node type defaults with user parameters
|
||||
* - Auto-adds required metadata for filter-based nodes (IF v2.2+, Switch v3.2+)
|
||||
* - Fixes operator structure
|
||||
* - Prevents "Could not find property option" errors
|
||||
*/
|
||||
|
||||
import { INodeParameters } from 'n8n-workflow';
|
||||
import { logger } from '../utils/logger';
|
||||
import { WorkflowNode } from '../types/n8n-api';
|
||||
|
||||
/**
|
||||
* Sanitize a single node by adding required metadata
|
||||
*/
|
||||
export function sanitizeNode(node: WorkflowNode): WorkflowNode {
|
||||
const sanitized = { ...node };
|
||||
|
||||
// Apply node-specific sanitization
|
||||
if (isFilterBasedNode(node.type, node.typeVersion)) {
|
||||
sanitized.parameters = sanitizeFilterBasedNode(
|
||||
sanitized.parameters as INodeParameters,
|
||||
node.type,
|
||||
node.typeVersion
|
||||
);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize all nodes in a workflow
|
||||
*/
|
||||
export function sanitizeWorkflowNodes(workflow: any): any {
|
||||
if (!workflow.nodes || !Array.isArray(workflow.nodes)) {
|
||||
return workflow;
|
||||
}
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
nodes: workflow.nodes.map((node: any) => sanitizeNode(node))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node is filter-based (IF v2.2+, Switch v3.2+)
|
||||
*/
|
||||
function isFilterBasedNode(nodeType: string, typeVersion: number): boolean {
|
||||
if (nodeType === 'n8n-nodes-base.if') {
|
||||
return typeVersion >= 2.2;
|
||||
}
|
||||
if (nodeType === 'n8n-nodes-base.switch') {
|
||||
return typeVersion >= 3.2;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filter-based nodes (IF v2.2+, Switch v3.2+)
|
||||
* Ensures conditions.options has complete structure
|
||||
*/
|
||||
function sanitizeFilterBasedNode(
|
||||
parameters: INodeParameters,
|
||||
nodeType: string,
|
||||
typeVersion: number
|
||||
): INodeParameters {
|
||||
const sanitized = { ...parameters };
|
||||
|
||||
// Handle IF node
|
||||
if (nodeType === 'n8n-nodes-base.if' && typeVersion >= 2.2) {
|
||||
sanitized.conditions = sanitizeFilterConditions(sanitized.conditions as any);
|
||||
}
|
||||
|
||||
// Handle Switch node
|
||||
if (nodeType === 'n8n-nodes-base.switch' && typeVersion >= 3.2) {
|
||||
if (sanitized.rules && typeof sanitized.rules === 'object') {
|
||||
const rules = sanitized.rules as any;
|
||||
if (rules.rules && Array.isArray(rules.rules)) {
|
||||
rules.rules = rules.rules.map((rule: any) => ({
|
||||
...rule,
|
||||
conditions: sanitizeFilterConditions(rule.conditions)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filter conditions structure
|
||||
*/
|
||||
function sanitizeFilterConditions(conditions: any): any {
|
||||
if (!conditions || typeof conditions !== 'object') {
|
||||
return conditions;
|
||||
}
|
||||
|
||||
const sanitized = { ...conditions };
|
||||
|
||||
// Ensure options has complete structure
|
||||
if (!sanitized.options) {
|
||||
sanitized.options = {};
|
||||
}
|
||||
|
||||
// Add required filter options metadata
|
||||
const requiredOptions = {
|
||||
version: 2,
|
||||
leftValue: '',
|
||||
caseSensitive: true,
|
||||
typeValidation: 'strict'
|
||||
};
|
||||
|
||||
// Merge with existing options, preserving user values
|
||||
sanitized.options = {
|
||||
...requiredOptions,
|
||||
...sanitized.options
|
||||
};
|
||||
|
||||
// Sanitize conditions array
|
||||
if (sanitized.conditions && Array.isArray(sanitized.conditions)) {
|
||||
sanitized.conditions = sanitized.conditions.map((condition: any) =>
|
||||
sanitizeCondition(condition)
|
||||
);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a single condition
|
||||
*/
|
||||
function sanitizeCondition(condition: any): any {
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return condition;
|
||||
}
|
||||
|
||||
const sanitized = { ...condition };
|
||||
|
||||
// Ensure condition has an ID
|
||||
if (!sanitized.id) {
|
||||
sanitized.id = generateConditionId();
|
||||
}
|
||||
|
||||
// Sanitize operator structure
|
||||
if (sanitized.operator) {
|
||||
sanitized.operator = sanitizeOperator(sanitized.operator);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize operator structure
|
||||
* Ensures operator has correct format: {type, operation, singleValue?}
|
||||
*/
|
||||
function sanitizeOperator(operator: any): any {
|
||||
if (!operator || typeof operator !== 'object') {
|
||||
return operator;
|
||||
}
|
||||
|
||||
const sanitized = { ...operator };
|
||||
|
||||
// Fix common mistake: type field used for operation name
|
||||
// WRONG: {type: "isNotEmpty"}
|
||||
// RIGHT: {type: "string", operation: "isNotEmpty"}
|
||||
if (sanitized.type && !sanitized.operation) {
|
||||
// Check if type value looks like an operation (lowercase, no dots)
|
||||
const typeValue = sanitized.type as string;
|
||||
if (isOperationName(typeValue)) {
|
||||
logger.debug(`Fixing operator structure: converting type="${typeValue}" to operation`);
|
||||
|
||||
// Infer data type from operation
|
||||
const dataType = inferDataType(typeValue);
|
||||
sanitized.type = dataType;
|
||||
sanitized.operation = typeValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Set singleValue based on operator type
|
||||
if (sanitized.operation) {
|
||||
if (isUnaryOperator(sanitized.operation)) {
|
||||
// Unary operators require singleValue: true
|
||||
sanitized.singleValue = true;
|
||||
} else {
|
||||
// Binary operators should NOT have singleValue (or it should be false/undefined)
|
||||
// Remove it to prevent UI errors
|
||||
delete sanitized.singleValue;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string looks like an operation name (not a data type)
|
||||
*/
|
||||
function isOperationName(value: string): boolean {
|
||||
// Operation names are lowercase and don't contain dots
|
||||
// Data types are: string, number, boolean, dateTime, array, object
|
||||
const dataTypes = ['string', 'number', 'boolean', 'dateTime', 'array', 'object'];
|
||||
return !dataTypes.includes(value) && /^[a-z][a-zA-Z]*$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer data type from operation name
|
||||
*/
|
||||
function inferDataType(operation: string): string {
|
||||
// Boolean operations
|
||||
const booleanOps = ['true', 'false', 'isEmpty', 'isNotEmpty'];
|
||||
if (booleanOps.includes(operation)) {
|
||||
return 'boolean';
|
||||
}
|
||||
|
||||
// Number operations
|
||||
const numberOps = ['isNumeric', 'gt', 'gte', 'lt', 'lte'];
|
||||
if (numberOps.some(op => operation.includes(op))) {
|
||||
return 'number';
|
||||
}
|
||||
|
||||
// Date operations
|
||||
const dateOps = ['after', 'before', 'afterDate', 'beforeDate'];
|
||||
if (dateOps.some(op => operation.includes(op))) {
|
||||
return 'dateTime';
|
||||
}
|
||||
|
||||
// Default to string
|
||||
return 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if operator is unary (requires singleValue: true)
|
||||
*/
|
||||
function isUnaryOperator(operation: string): boolean {
|
||||
const unaryOps = [
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'true',
|
||||
'false',
|
||||
'isNumeric'
|
||||
];
|
||||
return unaryOps.includes(operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique condition ID
|
||||
*/
|
||||
function generateConditionId(): string {
|
||||
return `condition-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a node has complete metadata
|
||||
* Returns array of issues found
|
||||
*/
|
||||
export function validateNodeMetadata(node: WorkflowNode): string[] {
|
||||
const issues: string[] = [];
|
||||
|
||||
if (!isFilterBasedNode(node.type, node.typeVersion)) {
|
||||
return issues; // Not a filter-based node
|
||||
}
|
||||
|
||||
// Check IF node
|
||||
if (node.type === 'n8n-nodes-base.if') {
|
||||
const conditions = (node.parameters.conditions as any);
|
||||
if (!conditions?.options) {
|
||||
issues.push('Missing conditions.options');
|
||||
} else {
|
||||
const required = ['version', 'leftValue', 'typeValidation', 'caseSensitive'];
|
||||
for (const field of required) {
|
||||
if (!(field in conditions.options)) {
|
||||
issues.push(`Missing conditions.options.${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check operators
|
||||
if (conditions?.conditions && Array.isArray(conditions.conditions)) {
|
||||
for (let i = 0; i < conditions.conditions.length; i++) {
|
||||
const condition = conditions.conditions[i];
|
||||
const operatorIssues = validateOperator(condition.operator, `conditions.conditions[${i}].operator`);
|
||||
issues.push(...operatorIssues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check Switch node
|
||||
if (node.type === 'n8n-nodes-base.switch') {
|
||||
const rules = (node.parameters.rules as any);
|
||||
if (rules?.rules && Array.isArray(rules.rules)) {
|
||||
for (let i = 0; i < rules.rules.length; i++) {
|
||||
const rule = rules.rules[i];
|
||||
if (!rule.conditions?.options) {
|
||||
issues.push(`Missing rules.rules[${i}].conditions.options`);
|
||||
} else {
|
||||
const required = ['version', 'leftValue', 'typeValidation', 'caseSensitive'];
|
||||
for (const field of required) {
|
||||
if (!(field in rule.conditions.options)) {
|
||||
issues.push(`Missing rules.rules[${i}].conditions.options.${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check operators
|
||||
if (rule.conditions?.conditions && Array.isArray(rule.conditions.conditions)) {
|
||||
for (let j = 0; j < rule.conditions.conditions.length; j++) {
|
||||
const condition = rule.conditions.conditions[j];
|
||||
const operatorIssues = validateOperator(
|
||||
condition.operator,
|
||||
`rules.rules[${i}].conditions.conditions[${j}].operator`
|
||||
);
|
||||
issues.push(...operatorIssues);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate operator structure
|
||||
*/
|
||||
function validateOperator(operator: any, path: string): string[] {
|
||||
const issues: string[] = [];
|
||||
|
||||
if (!operator || typeof operator !== 'object') {
|
||||
issues.push(`${path}: operator is missing or not an object`);
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (!operator.type) {
|
||||
issues.push(`${path}: missing required field 'type'`);
|
||||
} else if (!['string', 'number', 'boolean', 'dateTime', 'array', 'object'].includes(operator.type)) {
|
||||
issues.push(`${path}: invalid type "${operator.type}" (must be data type, not operation)`);
|
||||
}
|
||||
|
||||
if (!operator.operation) {
|
||||
issues.push(`${path}: missing required field 'operation'`);
|
||||
}
|
||||
|
||||
// Check singleValue based on operator type
|
||||
if (operator.operation) {
|
||||
if (isUnaryOperator(operator.operation)) {
|
||||
// Unary operators MUST have singleValue: true
|
||||
if (operator.singleValue !== true) {
|
||||
issues.push(`${path}: unary operator "${operator.operation}" requires singleValue: true`);
|
||||
}
|
||||
} else {
|
||||
// Binary operators should NOT have singleValue
|
||||
if (operator.singleValue === true) {
|
||||
issues.push(`${path}: binary operator "${operator.operation}" should not have singleValue: true (only unary operators need this)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
@@ -234,17 +234,11 @@ export class NodeSpecificValidators {
|
||||
static validateGoogleSheets(context: NodeValidationContext): void {
|
||||
const { config, errors, warnings, suggestions } = context;
|
||||
const { operation } = config;
|
||||
|
||||
// Common validations
|
||||
if (!config.sheetId && !config.documentId) {
|
||||
errors.push({
|
||||
type: 'missing_required',
|
||||
property: 'sheetId',
|
||||
message: 'Spreadsheet ID is required',
|
||||
fix: 'Provide the Google Sheets document ID from the URL'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// NOTE: Skip sheetId validation - it comes from credentials, not configuration
|
||||
// In real workflows, sheetId is provided by Google Sheets credentials
|
||||
// See Phase 3 validation results: 113/124 failures were false positives for this
|
||||
|
||||
// Operation-specific validations
|
||||
switch (operation) {
|
||||
case 'append':
|
||||
@@ -260,22 +254,43 @@ export class NodeSpecificValidators {
|
||||
this.validateGoogleSheetsDelete(context);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// Range format validation
|
||||
if (config.range) {
|
||||
this.validateGoogleSheetsRange(config.range, errors, warnings);
|
||||
}
|
||||
|
||||
// FINAL STEP: Filter out sheetId errors (credential-provided field)
|
||||
// Remove any sheetId validation errors that might have been added by nested validators
|
||||
const filteredErrors: ValidationError[] = [];
|
||||
for (const error of errors) {
|
||||
// Skip sheetId errors - this field is provided by credentials
|
||||
if (error.property === 'sheetId' && error.type === 'missing_required') {
|
||||
continue;
|
||||
}
|
||||
// Skip errors about sheetId in nested paths (e.g., from resourceMapper validation)
|
||||
if (error.property && error.property.includes('sheetId') && error.type === 'missing_required') {
|
||||
continue;
|
||||
}
|
||||
filteredErrors.push(error);
|
||||
}
|
||||
|
||||
// Replace errors array with filtered version
|
||||
errors.length = 0;
|
||||
errors.push(...filteredErrors);
|
||||
}
|
||||
|
||||
private static validateGoogleSheetsAppend(context: NodeValidationContext): void {
|
||||
const { config, errors, warnings, autofix } = context;
|
||||
|
||||
if (!config.range) {
|
||||
|
||||
// In Google Sheets v4+, range is only required if NOT using the columns resourceMapper
|
||||
// The columns parameter is a resourceMapper introduced in v4 that handles range automatically
|
||||
if (!config.range && !config.columns) {
|
||||
errors.push({
|
||||
type: 'missing_required',
|
||||
property: 'range',
|
||||
message: 'Range is required for append operation',
|
||||
fix: 'Specify range like "Sheet1!A:B" or "Sheet1!A1:B10"'
|
||||
message: 'Range or columns mapping is required for append operation',
|
||||
fix: 'Specify range like "Sheet1!A:B" OR use columns with mappingMode'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -716,9 +731,110 @@ export class NodeSpecificValidators {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate MySQL node configuration
|
||||
* Validate AI Agent node configuration
|
||||
* Note: This provides basic model connection validation at the node level.
|
||||
* Full AI workflow validation (tools, memory, etc.) is handled by workflow-validator.
|
||||
*/
|
||||
static validateAIAgent(context: NodeValidationContext): void {
|
||||
const { config, errors, warnings, suggestions, autofix } = context;
|
||||
|
||||
// Check for language model configuration
|
||||
// AI Agent nodes receive model connections via ai_languageModel connection type
|
||||
// We validate this during workflow validation, but provide hints here for common issues
|
||||
|
||||
// Check prompt type configuration
|
||||
if (config.promptType === 'define') {
|
||||
if (!config.text || (typeof config.text === 'string' && config.text.trim() === '')) {
|
||||
errors.push({
|
||||
type: 'missing_required',
|
||||
property: 'text',
|
||||
message: 'Custom prompt text is required when promptType is "define"',
|
||||
fix: 'Provide a custom prompt in the text field, or change promptType to "auto"'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check system message (RECOMMENDED)
|
||||
if (!config.systemMessage || (typeof config.systemMessage === 'string' && config.systemMessage.trim() === '')) {
|
||||
suggestions.push('AI Agent works best with a system message that defines the agent\'s role, capabilities, and constraints. Set systemMessage to provide context.');
|
||||
} else if (typeof config.systemMessage === 'string' && config.systemMessage.trim().length < 20) {
|
||||
warnings.push({
|
||||
type: 'inefficient',
|
||||
property: 'systemMessage',
|
||||
message: 'System message is very short (< 20 characters)',
|
||||
suggestion: 'Consider a more detailed system message to guide the agent\'s behavior'
|
||||
});
|
||||
}
|
||||
|
||||
// Check output parser configuration
|
||||
if (config.hasOutputParser === true) {
|
||||
warnings.push({
|
||||
type: 'best_practice',
|
||||
property: 'hasOutputParser',
|
||||
message: 'Output parser is enabled. Ensure an ai_outputParser connection is configured in the workflow.',
|
||||
suggestion: 'Connect an output parser node (e.g., Structured Output Parser) via ai_outputParser connection type'
|
||||
});
|
||||
}
|
||||
|
||||
// Check fallback model configuration
|
||||
if (config.needsFallback === true) {
|
||||
warnings.push({
|
||||
type: 'best_practice',
|
||||
property: 'needsFallback',
|
||||
message: 'Fallback model is enabled. Ensure 2 language models are connected via ai_languageModel connections.',
|
||||
suggestion: 'Connect a primary model and a fallback model to handle failures gracefully'
|
||||
});
|
||||
}
|
||||
|
||||
// Check maxIterations
|
||||
if (config.maxIterations !== undefined) {
|
||||
const maxIter = Number(config.maxIterations);
|
||||
if (isNaN(maxIter) || maxIter < 1) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: 'maxIterations',
|
||||
message: 'maxIterations must be a positive number',
|
||||
fix: 'Set maxIterations to a value >= 1 (e.g., 10)'
|
||||
});
|
||||
} else if (maxIter > 50) {
|
||||
warnings.push({
|
||||
type: 'inefficient',
|
||||
property: 'maxIterations',
|
||||
message: `maxIterations is set to ${maxIter}. High values can lead to long execution times and high costs.`,
|
||||
suggestion: 'Consider reducing maxIterations to 10-20 for most use cases'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling for AI operations
|
||||
if (!config.onError && !config.retryOnFail && !config.continueOnFail) {
|
||||
warnings.push({
|
||||
type: 'best_practice',
|
||||
property: 'errorHandling',
|
||||
message: 'AI models can fail due to API limits, rate limits, or invalid responses',
|
||||
suggestion: 'Add onError: "continueRegularOutput" with retryOnFail for resilience'
|
||||
});
|
||||
autofix.onError = 'continueRegularOutput';
|
||||
autofix.retryOnFail = true;
|
||||
autofix.maxTries = 2;
|
||||
autofix.waitBetweenTries = 5000; // AI models may have rate limits
|
||||
}
|
||||
|
||||
// Check for deprecated continueOnFail
|
||||
if (config.continueOnFail !== undefined) {
|
||||
warnings.push({
|
||||
type: 'deprecated',
|
||||
property: 'continueOnFail',
|
||||
message: 'continueOnFail is deprecated. Use onError instead',
|
||||
suggestion: 'Replace with onError: "continueRegularOutput" or "stopWorkflow"'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate MySQL node configuration
|
||||
*/
|
||||
static validateMySQL(context: NodeValidationContext): void {
|
||||
const { config, errors, warnings, suggestions } = context;
|
||||
@@ -1036,16 +1152,9 @@ export class NodeSpecificValidators {
|
||||
delete autofix.continueOnFail;
|
||||
}
|
||||
|
||||
// Response mode validation
|
||||
if (responseMode === 'responseNode' && !config.onError && !config.continueOnFail) {
|
||||
errors.push({
|
||||
type: 'invalid_configuration',
|
||||
property: 'responseMode',
|
||||
message: 'responseNode mode requires onError: "continueRegularOutput"',
|
||||
fix: 'Set onError to ensure response is always sent'
|
||||
});
|
||||
}
|
||||
|
||||
// Note: responseNode mode validation moved to workflow-validator.ts
|
||||
// where it has access to node-level onError property (not just config/parameters)
|
||||
|
||||
// Always output data for debugging
|
||||
if (!config.alwaysOutputData) {
|
||||
suggestions.push('Enable alwaysOutputData to debug webhook payloads');
|
||||
@@ -1556,4 +1665,60 @@ export class NodeSpecificValidators {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Set node configuration
|
||||
*/
|
||||
static validateSet(context: NodeValidationContext): void {
|
||||
const { config, errors, warnings } = context;
|
||||
|
||||
// Validate jsonOutput when present (used in JSON mode or when directly setting JSON)
|
||||
if (config.jsonOutput !== undefined && config.jsonOutput !== null && config.jsonOutput !== '') {
|
||||
try {
|
||||
const parsed = JSON.parse(config.jsonOutput);
|
||||
|
||||
// Set node with JSON input expects an OBJECT {}, not an ARRAY []
|
||||
// This is a common mistake that n8n UI catches but our validator should too
|
||||
if (Array.isArray(parsed)) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: 'jsonOutput',
|
||||
message: 'Set node expects a JSON object {}, not an array []',
|
||||
fix: 'Either wrap array items as object properties: {"items": [...]}, OR use a different approach for multiple items'
|
||||
});
|
||||
}
|
||||
|
||||
// Warn about empty objects
|
||||
if (typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length === 0) {
|
||||
warnings.push({
|
||||
type: 'inefficient',
|
||||
property: 'jsonOutput',
|
||||
message: 'jsonOutput is an empty object - this node will output no data',
|
||||
suggestion: 'Add properties to the object or remove this node if not needed'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({
|
||||
type: 'syntax_error',
|
||||
property: 'jsonOutput',
|
||||
message: `Invalid JSON in jsonOutput: ${e instanceof Error ? e.message : 'Syntax error'}`,
|
||||
fix: 'Ensure jsonOutput contains valid JSON syntax'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate mode-specific requirements
|
||||
if (config.mode === 'manual') {
|
||||
// In manual mode, at least one field should be defined
|
||||
const hasFields = config.values && Object.keys(config.values).length > 0;
|
||||
if (!hasFields && !config.jsonOutput) {
|
||||
warnings.push({
|
||||
type: 'missing_common',
|
||||
message: 'Set node has no fields configured - will output empty items',
|
||||
suggestion: 'Add fields in the Values section or use JSON mode'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
377
src/services/node-version-service.ts
Normal file
377
src/services/node-version-service.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Node Version Service
|
||||
*
|
||||
* Central service for node version discovery, comparison, and upgrade path recommendation.
|
||||
* Provides caching for performance and integrates with the database and breaking change detector.
|
||||
*/
|
||||
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { BreakingChangeDetector } from './breaking-change-detector';
|
||||
|
||||
export interface NodeVersion {
|
||||
nodeType: string;
|
||||
version: string;
|
||||
packageName: string;
|
||||
displayName: string;
|
||||
isCurrentMax: boolean;
|
||||
minimumN8nVersion?: string;
|
||||
breakingChanges: any[];
|
||||
deprecatedProperties: string[];
|
||||
addedProperties: string[];
|
||||
releasedAt?: Date;
|
||||
}
|
||||
|
||||
export interface VersionComparison {
|
||||
nodeType: string;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
isOutdated: boolean;
|
||||
versionGap: number; // How many versions behind
|
||||
hasBreakingChanges: boolean;
|
||||
recommendUpgrade: boolean;
|
||||
confidence: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface UpgradePath {
|
||||
nodeType: string;
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
direct: boolean; // Can upgrade directly or needs intermediate steps
|
||||
intermediateVersions: string[]; // If multi-step upgrade needed
|
||||
totalBreakingChanges: number;
|
||||
autoMigratableChanges: number;
|
||||
manualRequiredChanges: number;
|
||||
estimatedEffort: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
steps: UpgradeStep[];
|
||||
}
|
||||
|
||||
export interface UpgradeStep {
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
breakingChanges: number;
|
||||
migrationHints: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Node Version Service with caching
|
||||
*/
|
||||
export class NodeVersionService {
|
||||
private versionCache: Map<string, NodeVersion[]> = new Map();
|
||||
private cacheTTL: number = 5 * 60 * 1000; // 5 minutes
|
||||
private cacheTimestamps: Map<string, number> = new Map();
|
||||
|
||||
constructor(
|
||||
private nodeRepository: NodeRepository,
|
||||
private breakingChangeDetector: BreakingChangeDetector
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all available versions for a node type
|
||||
*/
|
||||
getAvailableVersions(nodeType: string): NodeVersion[] {
|
||||
// Check cache first
|
||||
const cached = this.getCachedVersions(nodeType);
|
||||
if (cached) return cached;
|
||||
|
||||
// Query from database
|
||||
const versions = this.nodeRepository.getNodeVersions(nodeType);
|
||||
|
||||
// Cache the result
|
||||
this.cacheVersions(nodeType, versions);
|
||||
|
||||
return versions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest available version for a node type
|
||||
*/
|
||||
getLatestVersion(nodeType: string): string | null {
|
||||
const versions = this.getAvailableVersions(nodeType);
|
||||
|
||||
if (versions.length === 0) {
|
||||
// Fallback to main nodes table
|
||||
const node = this.nodeRepository.getNode(nodeType);
|
||||
return node?.version || null;
|
||||
}
|
||||
|
||||
// Find version marked as current max
|
||||
const maxVersion = versions.find(v => v.isCurrentMax);
|
||||
if (maxVersion) return maxVersion.version;
|
||||
|
||||
// Fallback: sort and get highest
|
||||
const sorted = versions.sort((a, b) => this.compareVersions(b.version, a.version));
|
||||
return sorted[0]?.version || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a node's current version against the latest available
|
||||
*/
|
||||
compareVersions(currentVersion: string, latestVersion: string): number {
|
||||
const parts1 = currentVersion.split('.').map(Number);
|
||||
const parts2 = latestVersion.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const p1 = parts1[i] || 0;
|
||||
const p2 = parts2[i] || 0;
|
||||
|
||||
if (p1 < p2) return -1;
|
||||
if (p1 > p2) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze if a node version is outdated and should be upgraded
|
||||
*/
|
||||
analyzeVersion(nodeType: string, currentVersion: string): VersionComparison {
|
||||
const latestVersion = this.getLatestVersion(nodeType);
|
||||
|
||||
if (!latestVersion) {
|
||||
return {
|
||||
nodeType,
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
isOutdated: false,
|
||||
versionGap: 0,
|
||||
hasBreakingChanges: false,
|
||||
recommendUpgrade: false,
|
||||
confidence: 'HIGH',
|
||||
reason: 'No version information available. Using current version.'
|
||||
};
|
||||
}
|
||||
|
||||
const comparison = this.compareVersions(currentVersion, latestVersion);
|
||||
const isOutdated = comparison < 0;
|
||||
|
||||
if (!isOutdated) {
|
||||
return {
|
||||
nodeType,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isOutdated: false,
|
||||
versionGap: 0,
|
||||
hasBreakingChanges: false,
|
||||
recommendUpgrade: false,
|
||||
confidence: 'HIGH',
|
||||
reason: 'Node is already at the latest version.'
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate version gap
|
||||
const versionGap = this.calculateVersionGap(currentVersion, latestVersion);
|
||||
|
||||
// Check for breaking changes
|
||||
const hasBreakingChanges = this.breakingChangeDetector.hasBreakingChanges(
|
||||
nodeType,
|
||||
currentVersion,
|
||||
latestVersion
|
||||
);
|
||||
|
||||
// Determine upgrade recommendation and confidence
|
||||
let recommendUpgrade = true;
|
||||
let confidence: 'HIGH' | 'MEDIUM' | 'LOW' = 'HIGH';
|
||||
let reason = `Version ${latestVersion} available. `;
|
||||
|
||||
if (hasBreakingChanges) {
|
||||
confidence = 'MEDIUM';
|
||||
reason += 'Contains breaking changes. Review before upgrading.';
|
||||
} else {
|
||||
reason += 'Safe to upgrade (no breaking changes detected).';
|
||||
}
|
||||
|
||||
if (versionGap > 2) {
|
||||
confidence = 'LOW';
|
||||
reason += ` Version gap is large (${versionGap} versions). Consider incremental upgrade.`;
|
||||
}
|
||||
|
||||
return {
|
||||
nodeType,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isOutdated,
|
||||
versionGap,
|
||||
hasBreakingChanges,
|
||||
recommendUpgrade,
|
||||
confidence,
|
||||
reason
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the version gap (number of versions between)
|
||||
*/
|
||||
private calculateVersionGap(fromVersion: string, toVersion: string): number {
|
||||
const from = fromVersion.split('.').map(Number);
|
||||
const to = toVersion.split('.').map(Number);
|
||||
|
||||
// Simple gap calculation based on version numbers
|
||||
let gap = 0;
|
||||
|
||||
for (let i = 0; i < Math.max(from.length, to.length); i++) {
|
||||
const f = from[i] || 0;
|
||||
const t = to[i] || 0;
|
||||
gap += Math.abs(t - f);
|
||||
}
|
||||
|
||||
return gap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest the best upgrade path for a node
|
||||
*/
|
||||
async suggestUpgradePath(nodeType: string, currentVersion: string): Promise<UpgradePath | null> {
|
||||
const latestVersion = this.getLatestVersion(nodeType);
|
||||
|
||||
if (!latestVersion) return null;
|
||||
|
||||
const comparison = this.compareVersions(currentVersion, latestVersion);
|
||||
if (comparison >= 0) return null; // Already at latest or newer
|
||||
|
||||
// Get all available versions between current and latest
|
||||
const allVersions = this.getAvailableVersions(nodeType);
|
||||
const intermediateVersions = allVersions
|
||||
.filter(v =>
|
||||
this.compareVersions(v.version, currentVersion) > 0 &&
|
||||
this.compareVersions(v.version, latestVersion) < 0
|
||||
)
|
||||
.map(v => v.version)
|
||||
.sort((a, b) => this.compareVersions(a, b));
|
||||
|
||||
// Analyze the upgrade
|
||||
const analysis = await this.breakingChangeDetector.analyzeVersionUpgrade(
|
||||
nodeType,
|
||||
currentVersion,
|
||||
latestVersion
|
||||
);
|
||||
|
||||
// Determine if direct upgrade is safe
|
||||
const versionGap = this.calculateVersionGap(currentVersion, latestVersion);
|
||||
const direct = versionGap <= 1 || !analysis.hasBreakingChanges;
|
||||
|
||||
// Generate upgrade steps
|
||||
const steps: UpgradeStep[] = [];
|
||||
|
||||
if (direct || intermediateVersions.length === 0) {
|
||||
// Direct upgrade
|
||||
steps.push({
|
||||
fromVersion: currentVersion,
|
||||
toVersion: latestVersion,
|
||||
breakingChanges: analysis.changes.filter(c => c.isBreaking).length,
|
||||
migrationHints: analysis.recommendations
|
||||
});
|
||||
} else {
|
||||
// Multi-step upgrade through intermediate versions
|
||||
let stepFrom = currentVersion;
|
||||
|
||||
for (const intermediateVersion of intermediateVersions) {
|
||||
const stepAnalysis = await this.breakingChangeDetector.analyzeVersionUpgrade(
|
||||
nodeType,
|
||||
stepFrom,
|
||||
intermediateVersion
|
||||
);
|
||||
|
||||
steps.push({
|
||||
fromVersion: stepFrom,
|
||||
toVersion: intermediateVersion,
|
||||
breakingChanges: stepAnalysis.changes.filter(c => c.isBreaking).length,
|
||||
migrationHints: stepAnalysis.recommendations
|
||||
});
|
||||
|
||||
stepFrom = intermediateVersion;
|
||||
}
|
||||
|
||||
// Final step to latest
|
||||
const finalStepAnalysis = await this.breakingChangeDetector.analyzeVersionUpgrade(
|
||||
nodeType,
|
||||
stepFrom,
|
||||
latestVersion
|
||||
);
|
||||
|
||||
steps.push({
|
||||
fromVersion: stepFrom,
|
||||
toVersion: latestVersion,
|
||||
breakingChanges: finalStepAnalysis.changes.filter(c => c.isBreaking).length,
|
||||
migrationHints: finalStepAnalysis.recommendations
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate estimated effort
|
||||
const totalBreakingChanges = steps.reduce((sum, step) => sum + step.breakingChanges, 0);
|
||||
let estimatedEffort: 'LOW' | 'MEDIUM' | 'HIGH' = 'LOW';
|
||||
|
||||
if (totalBreakingChanges > 5 || steps.length > 3) {
|
||||
estimatedEffort = 'HIGH';
|
||||
} else if (totalBreakingChanges > 2 || steps.length > 1) {
|
||||
estimatedEffort = 'MEDIUM';
|
||||
}
|
||||
|
||||
return {
|
||||
nodeType,
|
||||
fromVersion: currentVersion,
|
||||
toVersion: latestVersion,
|
||||
direct,
|
||||
intermediateVersions,
|
||||
totalBreakingChanges,
|
||||
autoMigratableChanges: analysis.autoMigratableCount,
|
||||
manualRequiredChanges: analysis.manualRequiredCount,
|
||||
estimatedEffort,
|
||||
steps
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific version exists for a node
|
||||
*/
|
||||
versionExists(nodeType: string, version: string): boolean {
|
||||
const versions = this.getAvailableVersions(nodeType);
|
||||
return versions.some(v => v.version === version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version metadata (breaking changes, added/deprecated properties)
|
||||
*/
|
||||
getVersionMetadata(nodeType: string, version: string): NodeVersion | null {
|
||||
const versionData = this.nodeRepository.getNodeVersion(nodeType, version);
|
||||
return versionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the version cache
|
||||
*/
|
||||
clearCache(nodeType?: string): void {
|
||||
if (nodeType) {
|
||||
this.versionCache.delete(nodeType);
|
||||
this.cacheTimestamps.delete(nodeType);
|
||||
} else {
|
||||
this.versionCache.clear();
|
||||
this.cacheTimestamps.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached versions if still valid
|
||||
*/
|
||||
private getCachedVersions(nodeType: string): NodeVersion[] | null {
|
||||
const cached = this.versionCache.get(nodeType);
|
||||
const timestamp = this.cacheTimestamps.get(nodeType);
|
||||
|
||||
if (cached && timestamp) {
|
||||
const age = Date.now() - timestamp;
|
||||
if (age < this.cacheTTL) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache versions with timestamp
|
||||
*/
|
||||
private cacheVersions(nodeType: string, versions: NodeVersion[]): void {
|
||||
this.versionCache.set(nodeType, versions);
|
||||
this.cacheTimestamps.set(nodeType, Date.now());
|
||||
}
|
||||
}
|
||||
423
src/services/post-update-validator.ts
Normal file
423
src/services/post-update-validator.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Post-Update Validator
|
||||
*
|
||||
* Generates comprehensive, AI-friendly migration reports after node version upgrades.
|
||||
* Provides actionable guidance for AI agents on what manual steps are needed.
|
||||
*
|
||||
* Validation includes:
|
||||
* - New required properties
|
||||
* - Deprecated/removed properties
|
||||
* - Behavior changes
|
||||
* - Step-by-step migration instructions
|
||||
*/
|
||||
|
||||
import { BreakingChangeDetector, DetectedChange } from './breaking-change-detector';
|
||||
import { MigrationResult } from './node-migration-service';
|
||||
import { NodeVersionService } from './node-version-service';
|
||||
|
||||
export interface PostUpdateGuidance {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
oldVersion: string;
|
||||
newVersion: string;
|
||||
migrationStatus: 'complete' | 'partial' | 'manual_required';
|
||||
requiredActions: RequiredAction[];
|
||||
deprecatedProperties: DeprecatedProperty[];
|
||||
behaviorChanges: BehaviorChange[];
|
||||
migrationSteps: string[];
|
||||
confidence: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
estimatedTime: string; // e.g., "5 minutes", "15 minutes"
|
||||
}
|
||||
|
||||
export interface RequiredAction {
|
||||
type: 'ADD_PROPERTY' | 'UPDATE_PROPERTY' | 'CONFIGURE_OPTION' | 'REVIEW_CONFIGURATION';
|
||||
property: string;
|
||||
reason: string;
|
||||
suggestedValue?: any;
|
||||
currentValue?: any;
|
||||
documentation?: string;
|
||||
priority: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
}
|
||||
|
||||
export interface DeprecatedProperty {
|
||||
property: string;
|
||||
status: 'removed' | 'deprecated';
|
||||
replacement?: string;
|
||||
action: 'remove' | 'replace' | 'ignore';
|
||||
impact: 'breaking' | 'warning';
|
||||
}
|
||||
|
||||
export interface BehaviorChange {
|
||||
aspect: string; // e.g., "data passing", "webhook handling"
|
||||
oldBehavior: string;
|
||||
newBehavior: string;
|
||||
impact: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
actionRequired: boolean;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
export class PostUpdateValidator {
|
||||
constructor(
|
||||
private versionService: NodeVersionService,
|
||||
private breakingChangeDetector: BreakingChangeDetector
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate comprehensive post-update guidance for a migrated node
|
||||
*/
|
||||
async generateGuidance(
|
||||
nodeId: string,
|
||||
nodeName: string,
|
||||
nodeType: string,
|
||||
oldVersion: string,
|
||||
newVersion: string,
|
||||
migrationResult: MigrationResult
|
||||
): Promise<PostUpdateGuidance> {
|
||||
// Analyze the version upgrade
|
||||
const analysis = await this.breakingChangeDetector.analyzeVersionUpgrade(
|
||||
nodeType,
|
||||
oldVersion,
|
||||
newVersion
|
||||
);
|
||||
|
||||
// Determine migration status
|
||||
const migrationStatus = this.determineMigrationStatus(migrationResult, analysis.changes);
|
||||
|
||||
// Generate required actions
|
||||
const requiredActions = this.generateRequiredActions(
|
||||
migrationResult,
|
||||
analysis.changes,
|
||||
nodeType
|
||||
);
|
||||
|
||||
// Identify deprecated properties
|
||||
const deprecatedProperties = this.identifyDeprecatedProperties(analysis.changes);
|
||||
|
||||
// Document behavior changes
|
||||
const behaviorChanges = this.documentBehaviorChanges(nodeType, oldVersion, newVersion);
|
||||
|
||||
// Generate step-by-step migration instructions
|
||||
const migrationSteps = this.generateMigrationSteps(
|
||||
requiredActions,
|
||||
deprecatedProperties,
|
||||
behaviorChanges
|
||||
);
|
||||
|
||||
// Calculate confidence and estimated time
|
||||
const confidence = this.calculateConfidence(requiredActions, migrationStatus);
|
||||
const estimatedTime = this.estimateTime(requiredActions, behaviorChanges);
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
nodeName,
|
||||
nodeType,
|
||||
oldVersion,
|
||||
newVersion,
|
||||
migrationStatus,
|
||||
requiredActions,
|
||||
deprecatedProperties,
|
||||
behaviorChanges,
|
||||
migrationSteps,
|
||||
confidence,
|
||||
estimatedTime
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the migration status based on results and changes
|
||||
*/
|
||||
private determineMigrationStatus(
|
||||
migrationResult: MigrationResult,
|
||||
changes: DetectedChange[]
|
||||
): 'complete' | 'partial' | 'manual_required' {
|
||||
if (migrationResult.remainingIssues.length === 0) {
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
const criticalIssues = changes.filter(c => c.isBreaking && !c.autoMigratable);
|
||||
|
||||
if (criticalIssues.length > 0) {
|
||||
return 'manual_required';
|
||||
}
|
||||
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate actionable required actions for the AI agent
|
||||
*/
|
||||
private generateRequiredActions(
|
||||
migrationResult: MigrationResult,
|
||||
changes: DetectedChange[],
|
||||
nodeType: string
|
||||
): RequiredAction[] {
|
||||
const actions: RequiredAction[] = [];
|
||||
|
||||
// Actions from remaining issues (not auto-migrated)
|
||||
const manualChanges = changes.filter(c => !c.autoMigratable);
|
||||
|
||||
for (const change of manualChanges) {
|
||||
actions.push({
|
||||
type: this.mapChangeTypeToActionType(change.changeType),
|
||||
property: change.propertyName,
|
||||
reason: change.migrationHint,
|
||||
suggestedValue: change.newValue,
|
||||
currentValue: change.oldValue,
|
||||
documentation: this.getPropertyDocumentation(nodeType, change.propertyName),
|
||||
priority: this.mapSeverityToPriority(change.severity)
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify deprecated or removed properties
|
||||
*/
|
||||
private identifyDeprecatedProperties(changes: DetectedChange[]): DeprecatedProperty[] {
|
||||
const deprecated: DeprecatedProperty[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
if (change.changeType === 'removed') {
|
||||
deprecated.push({
|
||||
property: change.propertyName,
|
||||
status: 'removed',
|
||||
replacement: change.migrationStrategy?.targetProperty,
|
||||
action: change.autoMigratable ? 'remove' : 'replace',
|
||||
impact: change.isBreaking ? 'breaking' : 'warning'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return deprecated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Document behavior changes for specific nodes
|
||||
*/
|
||||
private documentBehaviorChanges(
|
||||
nodeType: string,
|
||||
oldVersion: string,
|
||||
newVersion: string
|
||||
): BehaviorChange[] {
|
||||
const changes: BehaviorChange[] = [];
|
||||
|
||||
// Execute Workflow node behavior changes
|
||||
if (nodeType === 'n8n-nodes-base.executeWorkflow') {
|
||||
if (this.versionService.compareVersions(oldVersion, '1.1') < 0 &&
|
||||
this.versionService.compareVersions(newVersion, '1.1') >= 0) {
|
||||
changes.push({
|
||||
aspect: 'Data passing to sub-workflows',
|
||||
oldBehavior: 'Automatic data passing - all data from parent workflow automatically available',
|
||||
newBehavior: 'Explicit field mapping required - must define inputFieldMapping to pass specific fields',
|
||||
impact: 'HIGH',
|
||||
actionRequired: true,
|
||||
recommendation: 'Define inputFieldMapping with specific field mappings between parent and child workflows. Review data dependencies.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook node behavior changes
|
||||
if (nodeType === 'n8n-nodes-base.webhook') {
|
||||
if (this.versionService.compareVersions(oldVersion, '2.1') < 0 &&
|
||||
this.versionService.compareVersions(newVersion, '2.1') >= 0) {
|
||||
changes.push({
|
||||
aspect: 'Webhook persistence',
|
||||
oldBehavior: 'Webhook URL changes on workflow updates',
|
||||
newBehavior: 'Stable webhook URL via webhookId field',
|
||||
impact: 'MEDIUM',
|
||||
actionRequired: false,
|
||||
recommendation: 'Webhook URLs now remain stable across workflow updates. Update external systems if needed.'
|
||||
});
|
||||
}
|
||||
|
||||
if (this.versionService.compareVersions(oldVersion, '2.0') < 0 &&
|
||||
this.versionService.compareVersions(newVersion, '2.0') >= 0) {
|
||||
changes.push({
|
||||
aspect: 'Response handling',
|
||||
oldBehavior: 'Automatic response after webhook trigger',
|
||||
newBehavior: 'Configurable response mode (onReceived vs lastNode)',
|
||||
impact: 'MEDIUM',
|
||||
actionRequired: true,
|
||||
recommendation: 'Review responseMode setting. Use "onReceived" for immediate responses or "lastNode" to wait for workflow completion.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate step-by-step migration instructions for AI agents
|
||||
*/
|
||||
private generateMigrationSteps(
|
||||
requiredActions: RequiredAction[],
|
||||
deprecatedProperties: DeprecatedProperty[],
|
||||
behaviorChanges: BehaviorChange[]
|
||||
): string[] {
|
||||
const steps: string[] = [];
|
||||
let stepNumber = 1;
|
||||
|
||||
// Start with deprecations
|
||||
if (deprecatedProperties.length > 0) {
|
||||
steps.push(`Step ${stepNumber++}: Remove deprecated properties`);
|
||||
for (const dep of deprecatedProperties) {
|
||||
steps.push(` - Remove "${dep.property}" ${dep.replacement ? `(use "${dep.replacement}" instead)` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Then critical actions
|
||||
const criticalActions = requiredActions.filter(a => a.priority === 'CRITICAL');
|
||||
if (criticalActions.length > 0) {
|
||||
steps.push(`Step ${stepNumber++}: Address critical configuration requirements`);
|
||||
for (const action of criticalActions) {
|
||||
steps.push(` - ${action.property}: ${action.reason}`);
|
||||
if (action.suggestedValue !== undefined) {
|
||||
steps.push(` Suggested value: ${JSON.stringify(action.suggestedValue)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// High priority actions
|
||||
const highActions = requiredActions.filter(a => a.priority === 'HIGH');
|
||||
if (highActions.length > 0) {
|
||||
steps.push(`Step ${stepNumber++}: Configure required properties`);
|
||||
for (const action of highActions) {
|
||||
steps.push(` - ${action.property}: ${action.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Behavior change adaptations
|
||||
const actionRequiredChanges = behaviorChanges.filter(c => c.actionRequired);
|
||||
if (actionRequiredChanges.length > 0) {
|
||||
steps.push(`Step ${stepNumber++}: Adapt to behavior changes`);
|
||||
for (const change of actionRequiredChanges) {
|
||||
steps.push(` - ${change.aspect}: ${change.recommendation}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Medium/Low priority actions
|
||||
const otherActions = requiredActions.filter(a => a.priority === 'MEDIUM' || a.priority === 'LOW');
|
||||
if (otherActions.length > 0) {
|
||||
steps.push(`Step ${stepNumber++}: Review optional configurations`);
|
||||
for (const action of otherActions) {
|
||||
steps.push(` - ${action.property}: ${action.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Final validation step
|
||||
steps.push(`Step ${stepNumber}: Test workflow execution`);
|
||||
steps.push(' - Validate all node configurations');
|
||||
steps.push(' - Run a test execution');
|
||||
steps.push(' - Verify expected behavior');
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map change type to action type
|
||||
*/
|
||||
private mapChangeTypeToActionType(
|
||||
changeType: string
|
||||
): 'ADD_PROPERTY' | 'UPDATE_PROPERTY' | 'CONFIGURE_OPTION' | 'REVIEW_CONFIGURATION' {
|
||||
switch (changeType) {
|
||||
case 'added':
|
||||
return 'ADD_PROPERTY';
|
||||
case 'requirement_changed':
|
||||
case 'type_changed':
|
||||
return 'UPDATE_PROPERTY';
|
||||
case 'default_changed':
|
||||
return 'CONFIGURE_OPTION';
|
||||
default:
|
||||
return 'REVIEW_CONFIGURATION';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map severity to priority
|
||||
*/
|
||||
private mapSeverityToPriority(
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
): 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' {
|
||||
if (severity === 'HIGH') return 'CRITICAL';
|
||||
return severity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get documentation for a property (placeholder - would integrate with node docs)
|
||||
*/
|
||||
private getPropertyDocumentation(nodeType: string, propertyName: string): string {
|
||||
// In future, this would fetch from node documentation
|
||||
return `See n8n documentation for ${nodeType} - ${propertyName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall confidence in the migration
|
||||
*/
|
||||
private calculateConfidence(
|
||||
requiredActions: RequiredAction[],
|
||||
migrationStatus: 'complete' | 'partial' | 'manual_required'
|
||||
): 'HIGH' | 'MEDIUM' | 'LOW' {
|
||||
if (migrationStatus === 'complete') return 'HIGH';
|
||||
|
||||
const criticalActions = requiredActions.filter(a => a.priority === 'CRITICAL');
|
||||
|
||||
if (migrationStatus === 'manual_required' || criticalActions.length > 3) {
|
||||
return 'LOW';
|
||||
}
|
||||
|
||||
return 'MEDIUM';
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate time required for manual migration steps
|
||||
*/
|
||||
private estimateTime(
|
||||
requiredActions: RequiredAction[],
|
||||
behaviorChanges: BehaviorChange[]
|
||||
): string {
|
||||
const criticalCount = requiredActions.filter(a => a.priority === 'CRITICAL').length;
|
||||
const highCount = requiredActions.filter(a => a.priority === 'HIGH').length;
|
||||
const behaviorCount = behaviorChanges.filter(c => c.actionRequired).length;
|
||||
|
||||
const totalComplexity = criticalCount * 5 + highCount * 3 + behaviorCount * 2;
|
||||
|
||||
if (totalComplexity === 0) return '< 1 minute';
|
||||
if (totalComplexity <= 5) return '2-5 minutes';
|
||||
if (totalComplexity <= 10) return '5-10 minutes';
|
||||
if (totalComplexity <= 20) return '10-20 minutes';
|
||||
return '20+ minutes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable summary for logging/display
|
||||
*/
|
||||
generateSummary(guidance: PostUpdateGuidance): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`Node "${guidance.nodeName}" upgraded from v${guidance.oldVersion} to v${guidance.newVersion}`);
|
||||
lines.push(`Status: ${guidance.migrationStatus.toUpperCase()}`);
|
||||
lines.push(`Confidence: ${guidance.confidence}`);
|
||||
lines.push(`Estimated time: ${guidance.estimatedTime}`);
|
||||
|
||||
if (guidance.requiredActions.length > 0) {
|
||||
lines.push(`\nRequired actions: ${guidance.requiredActions.length}`);
|
||||
for (const action of guidance.requiredActions.slice(0, 3)) {
|
||||
lines.push(` - [${action.priority}] ${action.property}: ${action.reason}`);
|
||||
}
|
||||
if (guidance.requiredActions.length > 3) {
|
||||
lines.push(` ... and ${guidance.requiredActions.length - 3} more`);
|
||||
}
|
||||
}
|
||||
|
||||
if (guidance.behaviorChanges.length > 0) {
|
||||
lines.push(`\nBehavior changes: ${guidance.behaviorChanges.length}`);
|
||||
for (const change of guidance.behaviorChanges) {
|
||||
lines.push(` - ${change.aspect}: ${change.newBehavior}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
427
src/services/type-structure-service.ts
Normal file
427
src/services/type-structure-service.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Type Structure Service
|
||||
*
|
||||
* Provides methods to query and work with n8n property type structures.
|
||||
* This service is stateless and uses static methods following the project's
|
||||
* PropertyFilter and ConfigValidator patterns.
|
||||
*
|
||||
* @module services/type-structure-service
|
||||
* @since 2.23.0
|
||||
*/
|
||||
|
||||
import type { NodePropertyTypes } from 'n8n-workflow';
|
||||
import type { TypeStructure } from '../types/type-structures';
|
||||
import {
|
||||
isComplexType as isComplexTypeGuard,
|
||||
isPrimitiveType as isPrimitiveTypeGuard,
|
||||
} from '../types/type-structures';
|
||||
import { TYPE_STRUCTURES, COMPLEX_TYPE_EXAMPLES } from '../constants/type-structures';
|
||||
|
||||
/**
|
||||
* Result of type validation
|
||||
*/
|
||||
export interface TypeValidationResult {
|
||||
/**
|
||||
* Whether the value is valid for the type
|
||||
*/
|
||||
valid: boolean;
|
||||
|
||||
/**
|
||||
* Validation errors if invalid
|
||||
*/
|
||||
errors: string[];
|
||||
|
||||
/**
|
||||
* Warnings that don't prevent validity
|
||||
*/
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for querying and working with node property type structures
|
||||
*
|
||||
* Provides static methods to:
|
||||
* - Get type structure definitions
|
||||
* - Get example values
|
||||
* - Validate type compatibility
|
||||
* - Query type categories
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get structure for a type
|
||||
* const structure = TypeStructureService.getStructure('collection');
|
||||
* console.log(structure.description); // "A group of related properties..."
|
||||
*
|
||||
* // Get example value
|
||||
* const example = TypeStructureService.getExample('filter');
|
||||
* console.log(example.combinator); // "and"
|
||||
*
|
||||
* // Check if type is complex
|
||||
* if (TypeStructureService.isComplexType('resourceMapper')) {
|
||||
* console.log('This type needs special handling');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class TypeStructureService {
|
||||
/**
|
||||
* Get the structure definition for a property type
|
||||
*
|
||||
* Returns the complete structure definition including:
|
||||
* - Type category (primitive/object/collection/special)
|
||||
* - JavaScript type
|
||||
* - Expected structure for complex types
|
||||
* - Example values
|
||||
* - Validation rules
|
||||
*
|
||||
* @param type - The NodePropertyType to query
|
||||
* @returns Type structure definition, or null if type is unknown
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const structure = TypeStructureService.getStructure('string');
|
||||
* console.log(structure.jsType); // "string"
|
||||
* console.log(structure.example); // "Hello World"
|
||||
* ```
|
||||
*/
|
||||
static getStructure(type: NodePropertyTypes): TypeStructure | null {
|
||||
return TYPE_STRUCTURES[type] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all type structure definitions
|
||||
*
|
||||
* Returns a record of all 22 NodePropertyTypes with their structures.
|
||||
* Useful for documentation, validation setup, or UI generation.
|
||||
*
|
||||
* @returns Record mapping all types to their structures
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const allStructures = TypeStructureService.getAllStructures();
|
||||
* console.log(Object.keys(allStructures).length); // 22
|
||||
* ```
|
||||
*/
|
||||
static getAllStructures(): Record<NodePropertyTypes, TypeStructure> {
|
||||
return { ...TYPE_STRUCTURES };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get example value for a property type
|
||||
*
|
||||
* Returns a working example value that conforms to the type's
|
||||
* expected structure. Useful for testing, documentation, or
|
||||
* generating default values.
|
||||
*
|
||||
* @param type - The NodePropertyType to get an example for
|
||||
* @returns Example value, or null if type is unknown
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const example = TypeStructureService.getExample('number');
|
||||
* console.log(example); // 42
|
||||
*
|
||||
* const filterExample = TypeStructureService.getExample('filter');
|
||||
* console.log(filterExample.combinator); // "and"
|
||||
* ```
|
||||
*/
|
||||
static getExample(type: NodePropertyTypes): any {
|
||||
const structure = this.getStructure(type);
|
||||
return structure ? structure.example : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all example values for a property type
|
||||
*
|
||||
* Some types have multiple examples to show different use cases.
|
||||
* This returns all available examples, or falls back to the
|
||||
* primary example if only one exists.
|
||||
*
|
||||
* @param type - The NodePropertyType to get examples for
|
||||
* @returns Array of example values
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const examples = TypeStructureService.getExamples('string');
|
||||
* console.log(examples.length); // 4
|
||||
* console.log(examples[0]); // ""
|
||||
* console.log(examples[1]); // "A simple text"
|
||||
* ```
|
||||
*/
|
||||
static getExamples(type: NodePropertyTypes): any[] {
|
||||
const structure = this.getStructure(type);
|
||||
if (!structure) return [];
|
||||
|
||||
return structure.examples || [structure.example];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property type is complex
|
||||
*
|
||||
* Complex types have nested structures and require special
|
||||
* validation logic beyond simple type checking.
|
||||
*
|
||||
* Complex types: collection, fixedCollection, resourceLocator,
|
||||
* resourceMapper, filter, assignmentCollection
|
||||
*
|
||||
* @param type - The property type to check
|
||||
* @returns True if the type is complex
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* TypeStructureService.isComplexType('collection'); // true
|
||||
* TypeStructureService.isComplexType('string'); // false
|
||||
* ```
|
||||
*/
|
||||
static isComplexType(type: NodePropertyTypes): boolean {
|
||||
return isComplexTypeGuard(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property type is primitive
|
||||
*
|
||||
* Primitive types map to simple JavaScript values and only
|
||||
* need basic type validation.
|
||||
*
|
||||
* Primitive types: string, number, boolean, dateTime, color, json
|
||||
*
|
||||
* @param type - The property type to check
|
||||
* @returns True if the type is primitive
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* TypeStructureService.isPrimitiveType('string'); // true
|
||||
* TypeStructureService.isPrimitiveType('collection'); // false
|
||||
* ```
|
||||
*/
|
||||
static isPrimitiveType(type: NodePropertyTypes): boolean {
|
||||
return isPrimitiveTypeGuard(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all complex property types
|
||||
*
|
||||
* Returns an array of all property types that are classified
|
||||
* as complex (having nested structures).
|
||||
*
|
||||
* @returns Array of complex type names
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const complexTypes = TypeStructureService.getComplexTypes();
|
||||
* console.log(complexTypes);
|
||||
* // ['collection', 'fixedCollection', 'resourceLocator', ...]
|
||||
* ```
|
||||
*/
|
||||
static getComplexTypes(): NodePropertyTypes[] {
|
||||
return Object.entries(TYPE_STRUCTURES)
|
||||
.filter(([, structure]) => structure.type === 'collection' || structure.type === 'special')
|
||||
.filter(([type]) => this.isComplexType(type as NodePropertyTypes))
|
||||
.map(([type]) => type as NodePropertyTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all primitive property types
|
||||
*
|
||||
* Returns an array of all property types that are classified
|
||||
* as primitive (simple JavaScript values).
|
||||
*
|
||||
* @returns Array of primitive type names
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const primitiveTypes = TypeStructureService.getPrimitiveTypes();
|
||||
* console.log(primitiveTypes);
|
||||
* // ['string', 'number', 'boolean', 'dateTime', 'color', 'json']
|
||||
* ```
|
||||
*/
|
||||
static getPrimitiveTypes(): NodePropertyTypes[] {
|
||||
return Object.keys(TYPE_STRUCTURES).filter((type) =>
|
||||
this.isPrimitiveType(type as NodePropertyTypes)
|
||||
) as NodePropertyTypes[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-world examples for complex types
|
||||
*
|
||||
* Returns curated examples from actual n8n workflows showing
|
||||
* different usage patterns for complex types.
|
||||
*
|
||||
* @param type - The complex type to get examples for
|
||||
* @returns Object with named example scenarios, or null
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const examples = TypeStructureService.getComplexExamples('fixedCollection');
|
||||
* console.log(examples.httpHeaders);
|
||||
* // { headers: [{ name: 'Content-Type', value: 'application/json' }] }
|
||||
* ```
|
||||
*/
|
||||
static getComplexExamples(
|
||||
type: 'collection' | 'fixedCollection' | 'filter' | 'resourceMapper' | 'assignmentCollection'
|
||||
): Record<string, any> | null {
|
||||
return COMPLEX_TYPE_EXAMPLES[type] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate basic type compatibility of a value
|
||||
*
|
||||
* Performs simple type checking to verify a value matches the
|
||||
* expected JavaScript type for a property type. Does not perform
|
||||
* deep structure validation for complex types.
|
||||
*
|
||||
* @param value - The value to validate
|
||||
* @param type - The expected property type
|
||||
* @returns Validation result with errors if invalid
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = TypeStructureService.validateTypeCompatibility(
|
||||
* 'Hello',
|
||||
* 'string'
|
||||
* );
|
||||
* console.log(result.valid); // true
|
||||
*
|
||||
* const result2 = TypeStructureService.validateTypeCompatibility(
|
||||
* 123,
|
||||
* 'string'
|
||||
* );
|
||||
* console.log(result2.valid); // false
|
||||
* console.log(result2.errors[0]); // "Expected string but got number"
|
||||
* ```
|
||||
*/
|
||||
static validateTypeCompatibility(
|
||||
value: any,
|
||||
type: NodePropertyTypes
|
||||
): TypeValidationResult {
|
||||
const structure = this.getStructure(type);
|
||||
|
||||
if (!structure) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Unknown property type: ${type}`],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
if (!structure.validation?.allowEmpty) {
|
||||
errors.push(`Value is required for type ${type}`);
|
||||
}
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
// Check JavaScript type compatibility
|
||||
const actualType = Array.isArray(value) ? 'array' : typeof value;
|
||||
const expectedType = structure.jsType;
|
||||
|
||||
if (expectedType !== 'any' && actualType !== expectedType) {
|
||||
// Special case: expressions are strings but might be allowed
|
||||
const isExpression = typeof value === 'string' && value.includes('{{');
|
||||
if (isExpression && structure.validation?.allowExpressions) {
|
||||
warnings.push(
|
||||
`Value contains n8n expression - cannot validate type until runtime`
|
||||
);
|
||||
} else {
|
||||
errors.push(`Expected ${expectedType} but got ${actualType}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Additional validation for specific types
|
||||
if (type === 'dateTime' && typeof value === 'string') {
|
||||
const pattern = structure.validation?.pattern;
|
||||
if (pattern && !new RegExp(pattern).test(value)) {
|
||||
errors.push(`Invalid dateTime format. Expected ISO 8601 format.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'color' && typeof value === 'string') {
|
||||
const pattern = structure.validation?.pattern;
|
||||
if (pattern && !new RegExp(pattern).test(value)) {
|
||||
errors.push(`Invalid color format. Expected 6-digit hex color (e.g., #FF5733).`);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'json' && typeof value === 'string') {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
errors.push(`Invalid JSON string. Must be valid JSON when parsed.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type description
|
||||
*
|
||||
* Returns the human-readable description of what a property type
|
||||
* represents and how it should be used.
|
||||
*
|
||||
* @param type - The property type
|
||||
* @returns Description string, or null if type unknown
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const description = TypeStructureService.getDescription('filter');
|
||||
* console.log(description);
|
||||
* // "Defines conditions for filtering data with boolean logic"
|
||||
* ```
|
||||
*/
|
||||
static getDescription(type: NodePropertyTypes): string | null {
|
||||
const structure = this.getStructure(type);
|
||||
return structure ? structure.description : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type notes
|
||||
*
|
||||
* Returns additional notes, warnings, or usage tips for a type.
|
||||
* Not all types have notes.
|
||||
*
|
||||
* @param type - The property type
|
||||
* @returns Array of note strings, or empty array
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const notes = TypeStructureService.getNotes('filter');
|
||||
* console.log(notes[0]);
|
||||
* // "Advanced filtering UI in n8n"
|
||||
* ```
|
||||
*/
|
||||
static getNotes(type: NodePropertyTypes): string[] {
|
||||
const structure = this.getStructure(type);
|
||||
return structure?.notes || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JavaScript type for a property type
|
||||
*
|
||||
* Returns the underlying JavaScript type that the property
|
||||
* type maps to (string, number, boolean, object, array, any).
|
||||
*
|
||||
* @param type - The property type
|
||||
* @returns JavaScript type name, or null if unknown
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* TypeStructureService.getJavaScriptType('string'); // "string"
|
||||
* TypeStructureService.getJavaScriptType('collection'); // "object"
|
||||
* TypeStructureService.getJavaScriptType('multiOptions'); // "array"
|
||||
* ```
|
||||
*/
|
||||
static getJavaScriptType(
|
||||
type: NodePropertyTypes
|
||||
): 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any' | null {
|
||||
const structure = this.getStructure(type);
|
||||
return structure ? structure.jsType : null;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
} 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]' });
|
||||
|
||||
@@ -25,7 +29,9 @@ export type FixType =
|
||||
| 'typeversion-correction'
|
||||
| 'error-output-config'
|
||||
| 'node-type-correction'
|
||||
| 'webhook-missing-path';
|
||||
| 'webhook-missing-path'
|
||||
| 'typeversion-upgrade' // NEW: Proactive version upgrades
|
||||
| 'version-migration'; // NEW: Smart version migrations with breaking changes
|
||||
|
||||
export interface AutoFixConfig {
|
||||
applyFixes: boolean;
|
||||
@@ -53,6 +59,7 @@ export interface AutoFixResult {
|
||||
byType: Record<FixType, number>;
|
||||
byConfidence: Record<FixConfidenceLevel, number>;
|
||||
};
|
||||
postUpdateGuidance?: PostUpdateGuidance[]; // NEW: AI-friendly migration guidance
|
||||
}
|
||||
|
||||
export interface NodeFormatIssue extends ExpressionFormatIssue {
|
||||
@@ -91,25 +98,34 @@ export class WorkflowAutoFixer {
|
||||
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
|
||||
*/
|
||||
generateFixes(
|
||||
async generateFixes(
|
||||
workflow: Workflow,
|
||||
validationResult: WorkflowValidationResult,
|
||||
formatIssues: ExpressionFormatIssue[] = [],
|
||||
config: Partial<AutoFixConfig> = {}
|
||||
): AutoFixResult {
|
||||
): 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>();
|
||||
@@ -143,6 +159,16 @@ export class WorkflowAutoFixer {
|
||||
this.processWebhookPathFixes(validationResult, nodeMap, operations, fixes);
|
||||
}
|
||||
|
||||
// NEW: 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);
|
||||
}
|
||||
|
||||
// Filter by confidence threshold
|
||||
const filteredFixes = this.filterByConfidence(fixes, fullConfig.confidenceThreshold);
|
||||
const filteredOperations = this.filterOperationsByFixes(operations, filteredFixes, fixes);
|
||||
@@ -159,7 +185,8 @@ export class WorkflowAutoFixer {
|
||||
operations: limitedOperations,
|
||||
fixes: limitedFixes,
|
||||
summary,
|
||||
stats
|
||||
stats,
|
||||
postUpdateGuidance: postUpdateGuidance.length > 0 ? postUpdateGuidance : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -578,7 +605,9 @@ export class WorkflowAutoFixer {
|
||||
'typeversion-correction': 0,
|
||||
'error-output-config': 0,
|
||||
'node-type-correction': 0,
|
||||
'webhook-missing-path': 0
|
||||
'webhook-missing-path': 0,
|
||||
'typeversion-upgrade': 0,
|
||||
'version-migration': 0
|
||||
},
|
||||
byConfidence: {
|
||||
'high': 0,
|
||||
@@ -621,10 +650,186 @@ export class WorkflowAutoFixer {
|
||||
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 (parts.length === 0) {
|
||||
return `Fixed ${stats.total} ${stats.total === 1 ? 'issue' : 'issues'}`;
|
||||
}
|
||||
|
||||
return `Fixed ${parts.join(', ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
const operation: UpdateNodeOperation = {
|
||||
type: 'updateNode',
|
||||
nodeId: node.id,
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,16 +25,25 @@ import {
|
||||
UpdateNameOperation,
|
||||
AddTagOperation,
|
||||
RemoveTagOperation,
|
||||
ActivateWorkflowOperation,
|
||||
DeactivateWorkflowOperation,
|
||||
CleanStaleConnectionsOperation,
|
||||
ReplaceConnectionsOperation
|
||||
} from '../types/workflow-diff';
|
||||
import { Workflow, WorkflowNode, WorkflowConnection } from '../types/n8n-api';
|
||||
import { Logger } from '../utils/logger';
|
||||
import { validateWorkflowNode, validateWorkflowConnections } from './n8n-validation';
|
||||
import { sanitizeNode, sanitizeWorkflowNodes } from './node-sanitizer';
|
||||
import { isActivatableTrigger } from '../utils/node-type-utils';
|
||||
|
||||
const logger = new Logger({ prefix: '[WorkflowDiffEngine]' });
|
||||
|
||||
export class WorkflowDiffEngine {
|
||||
// Track node name changes during operations for connection reference updates
|
||||
private renameMap: Map<string, string> = new Map();
|
||||
// Track warnings during operation processing
|
||||
private warnings: WorkflowDiffValidationError[] = [];
|
||||
|
||||
/**
|
||||
* Apply diff operations to a workflow
|
||||
*/
|
||||
@@ -43,6 +52,10 @@ export class WorkflowDiffEngine {
|
||||
request: WorkflowDiffRequest
|
||||
): Promise<WorkflowDiffResult> {
|
||||
try {
|
||||
// Reset tracking for this diff operation
|
||||
this.renameMap.clear();
|
||||
this.warnings = [];
|
||||
|
||||
// Clone workflow to avoid modifying original
|
||||
const workflowCopy = JSON.parse(JSON.stringify(workflow));
|
||||
|
||||
@@ -93,6 +106,12 @@ export class WorkflowDiffEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Update connection references after all node renames (even in continueOnError mode)
|
||||
if (this.renameMap.size > 0 && appliedIndices.length > 0) {
|
||||
this.updateConnectionReferences(workflowCopy);
|
||||
logger.debug(`Auto-updated ${this.renameMap.size} node name references in connections (continueOnError mode)`);
|
||||
}
|
||||
|
||||
// If validateOnly flag is set, return success without applying
|
||||
if (request.validateOnly) {
|
||||
return {
|
||||
@@ -101,6 +120,7 @@ export class WorkflowDiffEngine {
|
||||
? 'Validation successful. All operations are valid.'
|
||||
: `Validation completed with ${errors.length} errors.`,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
warnings: this.warnings.length > 0 ? this.warnings : undefined,
|
||||
applied: appliedIndices,
|
||||
failed: failedIndices
|
||||
};
|
||||
@@ -113,6 +133,7 @@ export class WorkflowDiffEngine {
|
||||
operationsApplied: appliedIndices.length,
|
||||
message: `Applied ${appliedIndices.length} operations, ${failedIndices.length} failed (continueOnError mode)`,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
warnings: this.warnings.length > 0 ? this.warnings : undefined,
|
||||
applied: appliedIndices,
|
||||
failed: failedIndices
|
||||
};
|
||||
@@ -146,6 +167,12 @@ export class WorkflowDiffEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Update connection references after all node renames
|
||||
if (this.renameMap.size > 0) {
|
||||
this.updateConnectionReferences(workflowCopy);
|
||||
logger.debug(`Auto-updated ${this.renameMap.size} node name references in connections`);
|
||||
}
|
||||
|
||||
// Pass 2: Validate and apply other operations (connections, metadata)
|
||||
for (const { operation, index } of otherOperations) {
|
||||
const error = this.validateOperation(workflowCopy, operation);
|
||||
@@ -174,6 +201,13 @@ export class WorkflowDiffEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize ALL nodes in the workflow after operations are applied
|
||||
// This ensures existing invalid nodes (e.g., binary operators with singleValue: true)
|
||||
// are fixed automatically when any update is made to the workflow
|
||||
workflowCopy.nodes = workflowCopy.nodes.map((node: WorkflowNode) => sanitizeNode(node));
|
||||
|
||||
logger.debug('Applied full-workflow sanitization to all nodes');
|
||||
|
||||
// If validateOnly flag is set, return success without applying
|
||||
if (request.validateOnly) {
|
||||
return {
|
||||
@@ -183,11 +217,23 @@ export class WorkflowDiffEngine {
|
||||
}
|
||||
|
||||
const operationsApplied = request.operations.length;
|
||||
|
||||
// Extract activation flags from workflow object
|
||||
const shouldActivate = (workflowCopy as any)._shouldActivate === true;
|
||||
const shouldDeactivate = (workflowCopy as any)._shouldDeactivate === true;
|
||||
|
||||
// Clean up temporary flags
|
||||
delete (workflowCopy as any)._shouldActivate;
|
||||
delete (workflowCopy as any)._shouldDeactivate;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflow: workflowCopy,
|
||||
operationsApplied,
|
||||
message: `Successfully applied ${operationsApplied} operations (${nodeOperations.length} node ops, ${otherOperations.length} other ops)`
|
||||
message: `Successfully applied ${operationsApplied} operations (${nodeOperations.length} node ops, ${otherOperations.length} other ops)`,
|
||||
warnings: this.warnings.length > 0 ? this.warnings : undefined,
|
||||
shouldActivate: shouldActivate || undefined,
|
||||
shouldDeactivate: shouldDeactivate || undefined
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -230,6 +276,10 @@ export class WorkflowDiffEngine {
|
||||
case 'addTag':
|
||||
case 'removeTag':
|
||||
return null; // These are always valid
|
||||
case 'activateWorkflow':
|
||||
return this.validateActivateWorkflow(workflow, operation);
|
||||
case 'deactivateWorkflow':
|
||||
return this.validateDeactivateWorkflow(workflow, operation);
|
||||
case 'cleanStaleConnections':
|
||||
return this.validateCleanStaleConnections(workflow, operation);
|
||||
case 'replaceConnections':
|
||||
@@ -283,6 +333,12 @@ export class WorkflowDiffEngine {
|
||||
case 'removeTag':
|
||||
this.applyRemoveTag(workflow, operation);
|
||||
break;
|
||||
case 'activateWorkflow':
|
||||
this.applyActivateWorkflow(workflow, operation);
|
||||
break;
|
||||
case 'deactivateWorkflow':
|
||||
this.applyDeactivateWorkflow(workflow, operation);
|
||||
break;
|
||||
case 'cleanStaleConnections':
|
||||
this.applyCleanStaleConnections(workflow, operation);
|
||||
break;
|
||||
@@ -341,10 +397,38 @@ export class WorkflowDiffEngine {
|
||||
}
|
||||
|
||||
private validateUpdateNode(workflow: Workflow, operation: UpdateNodeOperation): string | null {
|
||||
// Check for common parameter mistake: "changes" instead of "updates" (Issue #392)
|
||||
const operationAny = operation as any;
|
||||
if (operationAny.changes && !operation.updates) {
|
||||
return `Invalid parameter 'changes'. The updateNode operation requires 'updates' (not 'changes'). Example: {type: "updateNode", nodeId: "abc", updates: {name: "New Name", "parameters.url": "https://example.com"}}`;
|
||||
}
|
||||
|
||||
// Check for missing required parameter
|
||||
if (!operation.updates) {
|
||||
return `Missing required parameter 'updates'. The updateNode operation requires an 'updates' object containing properties to modify. Example: {type: "updateNode", nodeId: "abc", updates: {name: "New Name"}}`;
|
||||
}
|
||||
|
||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||
if (!node) {
|
||||
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'updateNode');
|
||||
}
|
||||
|
||||
// Check for name collision if renaming
|
||||
if (operation.updates.name && operation.updates.name !== node.name) {
|
||||
const normalizedNewName = this.normalizeNodeName(operation.updates.name);
|
||||
const normalizedCurrentName = this.normalizeNodeName(node.name);
|
||||
|
||||
// Only check collision if the names are actually different after normalization
|
||||
if (normalizedNewName !== normalizedCurrentName) {
|
||||
const collision = workflow.nodes.find(n =>
|
||||
n.id !== node.id && this.normalizeNodeName(n.name) === normalizedNewName
|
||||
);
|
||||
if (collision) {
|
||||
return `Cannot rename node "${node.name}" to "${operation.updates.name}": A node with that name already exists (id: ${collision.id.substring(0, 8)}...). Please choose a different name.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -526,8 +610,11 @@ export class WorkflowDiffEngine {
|
||||
alwaysOutputData: operation.node.alwaysOutputData,
|
||||
executeOnce: operation.node.executeOnce
|
||||
};
|
||||
|
||||
workflow.nodes.push(newNode);
|
||||
|
||||
// Sanitize node to ensure complete metadata (filter options, operator structure, etc.)
|
||||
const sanitizedNode = sanitizeNode(newNode);
|
||||
|
||||
workflow.nodes.push(sanitizedNode);
|
||||
}
|
||||
|
||||
private applyRemoveNode(workflow: Workflow, operation: RemoveNodeOperation): void {
|
||||
@@ -567,11 +654,25 @@ export class WorkflowDiffEngine {
|
||||
private applyUpdateNode(workflow: Workflow, operation: UpdateNodeOperation): void {
|
||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||
if (!node) return;
|
||||
|
||||
|
||||
// Track node renames for connection reference updates
|
||||
if (operation.updates.name && operation.updates.name !== node.name) {
|
||||
const oldName = node.name;
|
||||
const newName = operation.updates.name;
|
||||
this.renameMap.set(oldName, newName);
|
||||
logger.debug(`Tracking rename: "${oldName}" → "${newName}"`);
|
||||
}
|
||||
|
||||
// Apply updates using dot notation
|
||||
Object.entries(operation.updates).forEach(([path, value]) => {
|
||||
this.setNestedProperty(node, path, value);
|
||||
});
|
||||
|
||||
// Sanitize node after updates to ensure metadata is complete
|
||||
const sanitized = sanitizeNode(node);
|
||||
|
||||
// Update the node in-place
|
||||
Object.assign(node, sanitized);
|
||||
}
|
||||
|
||||
private applyMoveNode(workflow: Workflow, operation: MoveNodeOperation): void {
|
||||
@@ -625,6 +726,24 @@ export class WorkflowDiffEngine {
|
||||
sourceIndex = operation.case;
|
||||
}
|
||||
|
||||
// Validation: Warn if using sourceIndex with If/Switch nodes without smart parameters
|
||||
if (sourceNode && operation.sourceIndex !== undefined && operation.branch === undefined && operation.case === undefined) {
|
||||
if (sourceNode.type === 'n8n-nodes-base.if') {
|
||||
this.warnings.push({
|
||||
operation: -1, // Not tied to specific operation index in request
|
||||
message: `Connection to If node "${operation.source}" uses sourceIndex=${operation.sourceIndex}. ` +
|
||||
`Consider using branch="true" or branch="false" for better clarity. ` +
|
||||
`If node outputs: main[0]=TRUE branch, main[1]=FALSE branch.`
|
||||
});
|
||||
} else if (sourceNode.type === 'n8n-nodes-base.switch') {
|
||||
this.warnings.push({
|
||||
operation: -1, // Not tied to specific operation index in request
|
||||
message: `Connection to Switch node "${operation.source}" uses sourceIndex=${operation.sourceIndex}. ` +
|
||||
`Consider using case=N for better clarity (case=0 for first output, case=1 for second, etc.).`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { sourceOutput, sourceIndex };
|
||||
}
|
||||
|
||||
@@ -742,10 +861,14 @@ export class WorkflowDiffEngine {
|
||||
|
||||
// Metadata operation appliers
|
||||
private applyUpdateSettings(workflow: Workflow, operation: UpdateSettingsOperation): void {
|
||||
if (!workflow.settings) {
|
||||
workflow.settings = {};
|
||||
// Only create/update settings if operation provides actual properties
|
||||
// This prevents creating empty settings objects that would be rejected by n8n API
|
||||
if (operation.settings && Object.keys(operation.settings).length > 0) {
|
||||
if (!workflow.settings) {
|
||||
workflow.settings = {};
|
||||
}
|
||||
Object.assign(workflow.settings, operation.settings);
|
||||
}
|
||||
Object.assign(workflow.settings, operation.settings);
|
||||
}
|
||||
|
||||
private applyUpdateName(workflow: Workflow, operation: UpdateNameOperation): void {
|
||||
@@ -763,13 +886,46 @@ export class WorkflowDiffEngine {
|
||||
|
||||
private applyRemoveTag(workflow: Workflow, operation: RemoveTagOperation): void {
|
||||
if (!workflow.tags) return;
|
||||
|
||||
|
||||
const index = workflow.tags.indexOf(operation.tag);
|
||||
if (index !== -1) {
|
||||
workflow.tags.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow activation operation validators
|
||||
private validateActivateWorkflow(workflow: Workflow, operation: ActivateWorkflowOperation): string | null {
|
||||
// Check if workflow has at least one activatable trigger
|
||||
// Issue #351: executeWorkflowTrigger cannot activate workflows
|
||||
const activatableTriggers = workflow.nodes.filter(
|
||||
node => !node.disabled && isActivatableTrigger(node.type)
|
||||
);
|
||||
|
||||
if (activatableTriggers.length === 0) {
|
||||
return 'Cannot activate workflow: No activatable trigger nodes found. Workflows must have at least one enabled trigger node (webhook, schedule, email, etc.). Note: executeWorkflowTrigger cannot activate workflows as they can only be invoked by other workflows.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private validateDeactivateWorkflow(workflow: Workflow, operation: DeactivateWorkflowOperation): string | null {
|
||||
// Deactivation is always valid - any workflow can be deactivated
|
||||
return null;
|
||||
}
|
||||
|
||||
// Workflow activation operation appliers
|
||||
private applyActivateWorkflow(workflow: Workflow, operation: ActivateWorkflowOperation): void {
|
||||
// Set flag in workflow object to indicate activation intent
|
||||
// The handler will call the API method after workflow update
|
||||
(workflow as any)._shouldActivate = true;
|
||||
}
|
||||
|
||||
private applyDeactivateWorkflow(workflow: Workflow, operation: DeactivateWorkflowOperation): void {
|
||||
// Set flag in workflow object to indicate deactivation intent
|
||||
// The handler will call the API method after workflow update
|
||||
(workflow as any)._shouldDeactivate = true;
|
||||
}
|
||||
|
||||
// Connection cleanup operation validators
|
||||
private validateCleanStaleConnections(workflow: Workflow, operation: CleanStaleConnectionsOperation): string | null {
|
||||
// This operation is always valid - it just cleans up what it finds
|
||||
@@ -880,6 +1036,59 @@ export class WorkflowDiffEngine {
|
||||
workflow.connections = operation.connections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all connection references when nodes are renamed.
|
||||
* This method is called after node operations to ensure connection integrity.
|
||||
*
|
||||
* Updates:
|
||||
* - Connection object keys (source node names)
|
||||
* - Connection target.node values (target node names)
|
||||
* - All output types (main, error, ai_tool, ai_languageModel, etc.)
|
||||
*
|
||||
* @param workflow - The workflow to update
|
||||
*/
|
||||
private updateConnectionReferences(workflow: Workflow): void {
|
||||
if (this.renameMap.size === 0) return;
|
||||
|
||||
logger.debug(`Updating connection references for ${this.renameMap.size} renamed nodes`);
|
||||
|
||||
// Create a mapping of all renames (old → new)
|
||||
const renames = new Map(this.renameMap);
|
||||
|
||||
// Step 1: Update connection object keys (source node names)
|
||||
const updatedConnections: WorkflowConnection = {};
|
||||
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
|
||||
// Check if this source node was renamed
|
||||
const newSourceName = renames.get(sourceName) || sourceName;
|
||||
updatedConnections[newSourceName] = outputs;
|
||||
}
|
||||
|
||||
// Step 2: Update target node references within connections
|
||||
for (const [sourceName, outputs] of Object.entries(updatedConnections)) {
|
||||
// Iterate through all output types (main, error, ai_tool, ai_languageModel, etc.)
|
||||
for (const [outputType, connections] of Object.entries(outputs)) {
|
||||
// connections is Array<Array<{node, type, index}>>
|
||||
for (let outputIndex = 0; outputIndex < connections.length; outputIndex++) {
|
||||
const connectionsAtIndex = connections[outputIndex];
|
||||
for (let connIndex = 0; connIndex < connectionsAtIndex.length; connIndex++) {
|
||||
const connection = connectionsAtIndex[connIndex];
|
||||
// Check if target node was renamed
|
||||
if (renames.has(connection.node)) {
|
||||
const newTargetName = renames.get(connection.node)!;
|
||||
connection.node = newTargetName;
|
||||
logger.debug(`Updated connection: ${sourceName}[${outputType}][${outputIndex}][${connIndex}].node: "${connection.node}" → "${newTargetName}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace workflow connections with updated connections
|
||||
workflow.connections = updatedConnections;
|
||||
|
||||
logger.info(`Auto-updated ${this.renameMap.size} node name references in connections`);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Validates complete workflow structure, connections, and node configurations
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { EnhancedConfigValidator } from './enhanced-config-validator';
|
||||
import { ExpressionValidator } from './expression-validator';
|
||||
@@ -11,6 +12,8 @@ import { NodeSimilarityService, NodeSuggestion } from './node-similarity-service
|
||||
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
|
||||
import { Logger } from '../utils/logger';
|
||||
import { validateAISpecificNodes, hasAINodes } from './ai-node-validator';
|
||||
import { isTriggerNode } from '../utils/node-type-utils';
|
||||
import { isNonExecutableNode } from '../utils/node-classification';
|
||||
const logger = new Logger({ prefix: '[WorkflowValidator]' });
|
||||
|
||||
interface WorkflowNode {
|
||||
@@ -85,17 +88,8 @@ export class WorkflowValidator {
|
||||
this.similarityService = new NodeSimilarityService(nodeRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a Sticky Note or other non-executable node
|
||||
*/
|
||||
private isStickyNote(node: WorkflowNode): boolean {
|
||||
const stickyNoteTypes = [
|
||||
'n8n-nodes-base.stickyNote',
|
||||
'nodes-base.stickyNote',
|
||||
'@n8n/n8n-nodes-base.stickyNote'
|
||||
];
|
||||
return stickyNoteTypes.includes(node.type);
|
||||
}
|
||||
// Note: isStickyNote logic moved to shared utility: src/utils/node-classification.ts
|
||||
// Use isNonExecutableNode(node.type) instead
|
||||
|
||||
/**
|
||||
* Validate a complete workflow
|
||||
@@ -146,7 +140,7 @@ export class WorkflowValidator {
|
||||
}
|
||||
|
||||
// Update statistics after null check (exclude sticky notes from counts)
|
||||
const executableNodes = Array.isArray(workflow.nodes) ? workflow.nodes.filter(n => !this.isStickyNote(n)) : [];
|
||||
const executableNodes = Array.isArray(workflow.nodes) ? workflow.nodes.filter(n => !isNonExecutableNode(n.type)) : [];
|
||||
result.statistics.totalNodes = executableNodes.length;
|
||||
result.statistics.enabledNodes = executableNodes.filter(n => !n.disabled).length;
|
||||
|
||||
@@ -304,8 +298,11 @@ export class WorkflowValidator {
|
||||
// Check for duplicate node names
|
||||
const nodeNames = new Set<string>();
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
const nodeIdToIndex = new Map<string, number>(); // Track which node index has which ID
|
||||
|
||||
for (let i = 0; i < workflow.nodes.length; i++) {
|
||||
const node = workflow.nodes[i];
|
||||
|
||||
if (nodeNames.has(node.name)) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
@@ -317,25 +314,22 @@ export class WorkflowValidator {
|
||||
nodeNames.add(node.name);
|
||||
|
||||
if (nodeIds.has(node.id)) {
|
||||
const firstNodeIndex = nodeIdToIndex.get(node.id);
|
||||
const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined;
|
||||
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
message: `Duplicate node ID: "${node.id}"`
|
||||
message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}"). Each node must have a unique ID. Generate a new UUID using crypto.randomUUID() - Example: {id: "${crypto.randomUUID()}", name: "${node.name}", type: "${node.type}", ...}`
|
||||
});
|
||||
} else {
|
||||
nodeIds.add(node.id);
|
||||
nodeIdToIndex.set(node.id, i);
|
||||
}
|
||||
nodeIds.add(node.id);
|
||||
}
|
||||
|
||||
// Count trigger nodes - normalize type names first
|
||||
const triggerNodes = workflow.nodes.filter(n => {
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(n.type);
|
||||
const lowerType = normalizedType.toLowerCase();
|
||||
return lowerType.includes('trigger') ||
|
||||
(lowerType.includes('webhook') && !lowerType.includes('respond')) ||
|
||||
normalizedType === 'nodes-base.start' ||
|
||||
normalizedType === 'nodes-base.manualTrigger' ||
|
||||
normalizedType === 'nodes-base.formTrigger';
|
||||
});
|
||||
// Count trigger nodes using shared trigger detection
|
||||
const triggerNodes = workflow.nodes.filter(n => isTriggerNode(n.type));
|
||||
result.statistics.triggerNodes = triggerNodes.length;
|
||||
|
||||
// Check for at least one trigger node
|
||||
@@ -356,7 +350,7 @@ export class WorkflowValidator {
|
||||
profile: string
|
||||
): Promise<void> {
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled || this.isStickyNote(node)) continue;
|
||||
if (node.disabled || isNonExecutableNode(node.type)) continue;
|
||||
|
||||
try {
|
||||
// Validate node name length
|
||||
@@ -632,16 +626,12 @@ export class WorkflowValidator {
|
||||
|
||||
// Check for orphaned nodes (exclude sticky notes)
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled || this.isStickyNote(node)) continue;
|
||||
if (node.disabled || isNonExecutableNode(node.type)) continue;
|
||||
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type);
|
||||
const isTrigger = normalizedType.toLowerCase().includes('trigger') ||
|
||||
normalizedType.toLowerCase().includes('webhook') ||
|
||||
normalizedType === 'nodes-base.start' ||
|
||||
normalizedType === 'nodes-base.manualTrigger' ||
|
||||
normalizedType === 'nodes-base.formTrigger';
|
||||
|
||||
if (!connectedNodes.has(node.name) && !isTrigger) {
|
||||
// Use shared trigger detection function for consistency
|
||||
const isNodeTrigger = isTriggerNode(node.type);
|
||||
|
||||
if (!connectedNodes.has(node.name) && !isNodeTrigger) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
@@ -877,7 +867,7 @@ export class WorkflowValidator {
|
||||
|
||||
// Build node type map (exclude sticky notes)
|
||||
workflow.nodes.forEach(node => {
|
||||
if (!this.isStickyNote(node)) {
|
||||
if (!isNonExecutableNode(node.type)) {
|
||||
nodeTypeMap.set(node.name, node.type);
|
||||
}
|
||||
});
|
||||
@@ -945,7 +935,7 @@ export class WorkflowValidator {
|
||||
|
||||
// Check from all executable nodes (exclude sticky notes)
|
||||
for (const node of workflow.nodes) {
|
||||
if (!this.isStickyNote(node) && !visited.has(node.name)) {
|
||||
if (!isNonExecutableNode(node.type) && !visited.has(node.name)) {
|
||||
if (hasCycleDFS(node.name)) return true;
|
||||
}
|
||||
}
|
||||
@@ -964,7 +954,7 @@ export class WorkflowValidator {
|
||||
const nodeNames = workflow.nodes.map(n => n.name);
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled || this.isStickyNote(node)) continue;
|
||||
if (node.disabled || isNonExecutableNode(node.type)) continue;
|
||||
|
||||
// Skip expression validation for langchain nodes
|
||||
// They have AI-specific validators and different expression rules
|
||||
@@ -1111,7 +1101,7 @@ export class WorkflowValidator {
|
||||
|
||||
// Check node-level error handling properties for ALL executable nodes
|
||||
for (const node of workflow.nodes) {
|
||||
if (!this.isStickyNote(node)) {
|
||||
if (!isNonExecutableNode(node.type)) {
|
||||
this.checkNodeErrorHandling(node, workflow, result);
|
||||
}
|
||||
}
|
||||
@@ -1292,6 +1282,15 @@ export class WorkflowValidator {
|
||||
|
||||
/**
|
||||
* Check node-level error handling configuration for a single node
|
||||
*
|
||||
* Validates error handling properties (onError, continueOnFail, retryOnFail)
|
||||
* and provides warnings for error-prone nodes (HTTP, webhooks, databases)
|
||||
* that lack proper error handling. Delegates webhook-specific validation
|
||||
* to checkWebhookErrorHandling() for clearer logic.
|
||||
*
|
||||
* @param node - The workflow node to validate
|
||||
* @param workflow - The complete workflow for context
|
||||
* @param result - Validation result to add errors/warnings to
|
||||
*/
|
||||
private checkNodeErrorHandling(
|
||||
node: WorkflowNode,
|
||||
@@ -1502,12 +1501,8 @@ export class WorkflowValidator {
|
||||
message: 'HTTP Request node without error handling. Consider adding "onError: \'continueRegularOutput\'" for non-critical requests or "retryOnFail: true" for transient failures.'
|
||||
});
|
||||
} else if (normalizedType.includes('webhook')) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: 'Webhook node without error handling. Consider adding "onError: \'continueRegularOutput\'" to prevent workflow failures from blocking webhook responses.'
|
||||
});
|
||||
// Delegate to specialized webhook validation helper
|
||||
this.checkWebhookErrorHandling(node, normalizedType, result);
|
||||
} else if (errorProneNodeTypes.some(db => normalizedType.includes(db) && ['postgres', 'mysql', 'mongodb'].includes(db))) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
@@ -1598,6 +1593,52 @@ export class WorkflowValidator {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Check webhook-specific error handling requirements
|
||||
*
|
||||
* Webhooks have special error handling requirements:
|
||||
* - respondToWebhook nodes (response nodes) don't need error handling
|
||||
* - Webhook nodes with responseNode mode REQUIRE onError to ensure responses
|
||||
* - Regular webhook nodes should have error handling to prevent blocking
|
||||
*
|
||||
* @param node - The webhook node to check
|
||||
* @param normalizedType - Normalized node type for comparison
|
||||
* @param result - Validation result to add errors/warnings to
|
||||
*/
|
||||
private checkWebhookErrorHandling(
|
||||
node: WorkflowNode,
|
||||
normalizedType: string,
|
||||
result: WorkflowValidationResult
|
||||
): void {
|
||||
// respondToWebhook nodes are response nodes (endpoints), not triggers
|
||||
// They're the END of execution, not controllers of flow - skip error handling check
|
||||
if (normalizedType.includes('respondtowebhook')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for responseNode mode specifically
|
||||
// responseNode mode requires onError to ensure response is sent even on error
|
||||
if (node.parameters?.responseMode === 'responseNode') {
|
||||
if (!node.onError && !node.continueOnFail) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: 'responseNode mode requires onError: "continueRegularOutput"'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular webhook nodes without responseNode mode
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: 'Webhook node without error handling. Consider adding "onError: \'continueRegularOutput\'" to prevent workflow failures from blocking webhook responses.'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate error handling suggestions based on all nodes
|
||||
*/
|
||||
|
||||
460
src/services/workflow-versioning-service.ts
Normal file
460
src/services/workflow-versioning-service.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Workflow Versioning Service
|
||||
*
|
||||
* Provides workflow backup, versioning, rollback, and cleanup capabilities.
|
||||
* Automatically prunes to 10 versions per workflow to prevent memory leaks.
|
||||
*/
|
||||
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { N8nApiClient } from './n8n-api-client';
|
||||
import { WorkflowValidator } from './workflow-validator';
|
||||
import { EnhancedConfigValidator } from './enhanced-config-validator';
|
||||
|
||||
export interface WorkflowVersion {
|
||||
id: number;
|
||||
workflowId: string;
|
||||
versionNumber: number;
|
||||
workflowName: string;
|
||||
workflowSnapshot: any;
|
||||
trigger: 'partial_update' | 'full_update' | 'autofix';
|
||||
operations?: any[];
|
||||
fixTypes?: string[];
|
||||
metadata?: any;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
id: number;
|
||||
workflowId: string;
|
||||
versionNumber: number;
|
||||
workflowName: string;
|
||||
trigger: string;
|
||||
operationCount?: number;
|
||||
fixTypesApplied?: string[];
|
||||
createdAt: string;
|
||||
size: number; // Size in bytes
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
workflowId: string;
|
||||
fromVersion?: number;
|
||||
toVersionId: number;
|
||||
backupCreated: boolean;
|
||||
backupVersionId?: number;
|
||||
validationErrors?: string[];
|
||||
}
|
||||
|
||||
export interface BackupResult {
|
||||
versionId: number;
|
||||
versionNumber: number;
|
||||
pruned: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
totalVersions: number;
|
||||
totalSize: number;
|
||||
totalSizeFormatted: string;
|
||||
byWorkflow: WorkflowStorageInfo[];
|
||||
}
|
||||
|
||||
export interface WorkflowStorageInfo {
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
versionCount: number;
|
||||
totalSize: number;
|
||||
totalSizeFormatted: string;
|
||||
lastBackup: string;
|
||||
}
|
||||
|
||||
export interface VersionDiff {
|
||||
versionId1: number;
|
||||
versionId2: number;
|
||||
version1Number: number;
|
||||
version2Number: number;
|
||||
addedNodes: string[];
|
||||
removedNodes: string[];
|
||||
modifiedNodes: string[];
|
||||
connectionChanges: number;
|
||||
settingChanges: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow Versioning Service
|
||||
*/
|
||||
export class WorkflowVersioningService {
|
||||
private readonly DEFAULT_MAX_VERSIONS = 10;
|
||||
|
||||
constructor(
|
||||
private nodeRepository: NodeRepository,
|
||||
private apiClient?: N8nApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create backup before modification
|
||||
* Automatically prunes to 10 versions after backup creation
|
||||
*/
|
||||
async createBackup(
|
||||
workflowId: string,
|
||||
workflow: any,
|
||||
context: {
|
||||
trigger: 'partial_update' | 'full_update' | 'autofix';
|
||||
operations?: any[];
|
||||
fixTypes?: string[];
|
||||
metadata?: any;
|
||||
}
|
||||
): Promise<BackupResult> {
|
||||
// Get current max version number
|
||||
const versions = this.nodeRepository.getWorkflowVersions(workflowId, 1);
|
||||
const nextVersion = versions.length > 0 ? versions[0].versionNumber + 1 : 1;
|
||||
|
||||
// Create new version
|
||||
const versionId = this.nodeRepository.createWorkflowVersion({
|
||||
workflowId,
|
||||
versionNumber: nextVersion,
|
||||
workflowName: workflow.name || 'Unnamed Workflow',
|
||||
workflowSnapshot: workflow,
|
||||
trigger: context.trigger,
|
||||
operations: context.operations,
|
||||
fixTypes: context.fixTypes,
|
||||
metadata: context.metadata
|
||||
});
|
||||
|
||||
// Auto-prune to keep max 10 versions
|
||||
const pruned = this.nodeRepository.pruneWorkflowVersions(
|
||||
workflowId,
|
||||
this.DEFAULT_MAX_VERSIONS
|
||||
);
|
||||
|
||||
return {
|
||||
versionId,
|
||||
versionNumber: nextVersion,
|
||||
pruned,
|
||||
message: pruned > 0
|
||||
? `Backup created (version ${nextVersion}), pruned ${pruned} old version(s)`
|
||||
: `Backup created (version ${nextVersion})`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version history for a workflow
|
||||
*/
|
||||
async getVersionHistory(workflowId: string, limit: number = 10): Promise<VersionInfo[]> {
|
||||
const versions = this.nodeRepository.getWorkflowVersions(workflowId, limit);
|
||||
|
||||
return versions.map(v => ({
|
||||
id: v.id,
|
||||
workflowId: v.workflowId,
|
||||
versionNumber: v.versionNumber,
|
||||
workflowName: v.workflowName,
|
||||
trigger: v.trigger,
|
||||
operationCount: v.operations ? v.operations.length : undefined,
|
||||
fixTypesApplied: v.fixTypes || undefined,
|
||||
createdAt: v.createdAt,
|
||||
size: JSON.stringify(v.workflowSnapshot).length
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific workflow version
|
||||
*/
|
||||
async getVersion(versionId: number): Promise<WorkflowVersion | null> {
|
||||
return this.nodeRepository.getWorkflowVersion(versionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore workflow to a previous version
|
||||
* Creates backup of current state before restoring
|
||||
*/
|
||||
async restoreVersion(
|
||||
workflowId: string,
|
||||
versionId?: number,
|
||||
validateBefore: boolean = true
|
||||
): Promise<RestoreResult> {
|
||||
if (!this.apiClient) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'API client not configured - cannot restore workflow',
|
||||
workflowId,
|
||||
toVersionId: versionId || 0,
|
||||
backupCreated: false
|
||||
};
|
||||
}
|
||||
|
||||
// Get the version to restore
|
||||
let versionToRestore: WorkflowVersion | null = null;
|
||||
|
||||
if (versionId) {
|
||||
versionToRestore = this.nodeRepository.getWorkflowVersion(versionId);
|
||||
} else {
|
||||
// Get latest backup
|
||||
versionToRestore = this.nodeRepository.getLatestWorkflowVersion(workflowId);
|
||||
}
|
||||
|
||||
if (!versionToRestore) {
|
||||
return {
|
||||
success: false,
|
||||
message: versionId
|
||||
? `Version ${versionId} not found`
|
||||
: `No backup versions found for workflow ${workflowId}`,
|
||||
workflowId,
|
||||
toVersionId: versionId || 0,
|
||||
backupCreated: false
|
||||
};
|
||||
}
|
||||
|
||||
// Validate workflow structure if requested
|
||||
if (validateBefore) {
|
||||
const validator = new WorkflowValidator(this.nodeRepository, EnhancedConfigValidator);
|
||||
const validationResult = await validator.validateWorkflow(
|
||||
versionToRestore.workflowSnapshot,
|
||||
{
|
||||
validateNodes: true,
|
||||
validateConnections: true,
|
||||
validateExpressions: false,
|
||||
profile: 'runtime'
|
||||
}
|
||||
);
|
||||
|
||||
if (validationResult.errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Cannot restore - version ${versionToRestore.versionNumber} has validation errors`,
|
||||
workflowId,
|
||||
toVersionId: versionToRestore.id,
|
||||
backupCreated: false,
|
||||
validationErrors: validationResult.errors.map(e => e.message || 'Unknown error')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup of current workflow before restoring
|
||||
let backupResult: BackupResult | undefined;
|
||||
try {
|
||||
const currentWorkflow = await this.apiClient.getWorkflow(workflowId);
|
||||
backupResult = await this.createBackup(workflowId, currentWorkflow, {
|
||||
trigger: 'partial_update',
|
||||
metadata: {
|
||||
reason: 'Backup before rollback',
|
||||
restoringToVersion: versionToRestore.versionNumber
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to create backup before restore: ${error.message}`,
|
||||
workflowId,
|
||||
toVersionId: versionToRestore.id,
|
||||
backupCreated: false
|
||||
};
|
||||
}
|
||||
|
||||
// Restore the workflow
|
||||
try {
|
||||
await this.apiClient.updateWorkflow(workflowId, versionToRestore.workflowSnapshot);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully restored workflow to version ${versionToRestore.versionNumber}`,
|
||||
workflowId,
|
||||
fromVersion: backupResult.versionNumber,
|
||||
toVersionId: versionToRestore.id,
|
||||
backupCreated: true,
|
||||
backupVersionId: backupResult.versionId
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to restore workflow: ${error.message}`,
|
||||
workflowId,
|
||||
toVersionId: versionToRestore.id,
|
||||
backupCreated: true,
|
||||
backupVersionId: backupResult.versionId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific version
|
||||
*/
|
||||
async deleteVersion(versionId: number): Promise<{ success: boolean; message: string }> {
|
||||
const version = this.nodeRepository.getWorkflowVersion(versionId);
|
||||
|
||||
if (!version) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Version ${versionId} not found`
|
||||
};
|
||||
}
|
||||
|
||||
this.nodeRepository.deleteWorkflowVersion(versionId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Deleted version ${version.versionNumber} for workflow ${version.workflowId}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all versions for a workflow
|
||||
*/
|
||||
async deleteAllVersions(workflowId: string): Promise<{ deleted: number; message: string }> {
|
||||
const count = this.nodeRepository.getWorkflowVersionCount(workflowId);
|
||||
|
||||
if (count === 0) {
|
||||
return {
|
||||
deleted: 0,
|
||||
message: `No versions found for workflow ${workflowId}`
|
||||
};
|
||||
}
|
||||
|
||||
const deleted = this.nodeRepository.deleteWorkflowVersionsByWorkflowId(workflowId);
|
||||
|
||||
return {
|
||||
deleted,
|
||||
message: `Deleted ${deleted} version(s) for workflow ${workflowId}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger pruning for a workflow
|
||||
*/
|
||||
async pruneVersions(
|
||||
workflowId: string,
|
||||
maxVersions: number = 10
|
||||
): Promise<{ pruned: number; remaining: number }> {
|
||||
const pruned = this.nodeRepository.pruneWorkflowVersions(workflowId, maxVersions);
|
||||
const remaining = this.nodeRepository.getWorkflowVersionCount(workflowId);
|
||||
|
||||
return { pruned, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate entire workflow_versions table
|
||||
* Requires explicit confirmation
|
||||
*/
|
||||
async truncateAllVersions(confirm: boolean): Promise<{ deleted: number; message: string }> {
|
||||
if (!confirm) {
|
||||
return {
|
||||
deleted: 0,
|
||||
message: 'Truncate operation not confirmed - no action taken'
|
||||
};
|
||||
}
|
||||
|
||||
const deleted = this.nodeRepository.truncateWorkflowVersions();
|
||||
|
||||
return {
|
||||
deleted,
|
||||
message: `Truncated workflow_versions table - deleted ${deleted} version(s)`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
*/
|
||||
async getStorageStats(): Promise<StorageStats> {
|
||||
const stats = this.nodeRepository.getVersionStorageStats();
|
||||
|
||||
return {
|
||||
totalVersions: stats.totalVersions,
|
||||
totalSize: stats.totalSize,
|
||||
totalSizeFormatted: this.formatBytes(stats.totalSize),
|
||||
byWorkflow: stats.byWorkflow.map((w: any) => ({
|
||||
workflowId: w.workflowId,
|
||||
workflowName: w.workflowName,
|
||||
versionCount: w.versionCount,
|
||||
totalSize: w.totalSize,
|
||||
totalSizeFormatted: this.formatBytes(w.totalSize),
|
||||
lastBackup: w.lastBackup
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions
|
||||
*/
|
||||
async compareVersions(versionId1: number, versionId2: number): Promise<VersionDiff> {
|
||||
const v1 = this.nodeRepository.getWorkflowVersion(versionId1);
|
||||
const v2 = this.nodeRepository.getWorkflowVersion(versionId2);
|
||||
|
||||
if (!v1 || !v2) {
|
||||
throw new Error(`One or both versions not found: ${versionId1}, ${versionId2}`);
|
||||
}
|
||||
|
||||
// Compare nodes
|
||||
const nodes1 = new Set<string>(v1.workflowSnapshot.nodes?.map((n: any) => n.id as string) || []);
|
||||
const nodes2 = new Set<string>(v2.workflowSnapshot.nodes?.map((n: any) => n.id as string) || []);
|
||||
|
||||
const addedNodes: string[] = [...nodes2].filter(id => !nodes1.has(id));
|
||||
const removedNodes: string[] = [...nodes1].filter(id => !nodes2.has(id));
|
||||
const commonNodes = [...nodes1].filter(id => nodes2.has(id));
|
||||
|
||||
// Check for modified nodes
|
||||
const modifiedNodes: string[] = [];
|
||||
for (const nodeId of commonNodes) {
|
||||
const node1 = v1.workflowSnapshot.nodes?.find((n: any) => n.id === nodeId);
|
||||
const node2 = v2.workflowSnapshot.nodes?.find((n: any) => n.id === nodeId);
|
||||
|
||||
if (JSON.stringify(node1) !== JSON.stringify(node2)) {
|
||||
modifiedNodes.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare connections
|
||||
const conn1Str = JSON.stringify(v1.workflowSnapshot.connections || {});
|
||||
const conn2Str = JSON.stringify(v2.workflowSnapshot.connections || {});
|
||||
const connectionChanges = conn1Str !== conn2Str ? 1 : 0;
|
||||
|
||||
// Compare settings
|
||||
const settings1 = v1.workflowSnapshot.settings || {};
|
||||
const settings2 = v2.workflowSnapshot.settings || {};
|
||||
const settingChanges = this.diffObjects(settings1, settings2);
|
||||
|
||||
return {
|
||||
versionId1,
|
||||
versionId2,
|
||||
version1Number: v1.versionNumber,
|
||||
version2Number: v2.versionNumber,
|
||||
addedNodes,
|
||||
removedNodes,
|
||||
modifiedNodes,
|
||||
connectionChanges,
|
||||
settingChanges
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable string
|
||||
*/
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple object diff
|
||||
*/
|
||||
private diffObjects(obj1: any, obj2: any): any {
|
||||
const changes: any = {};
|
||||
|
||||
const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (JSON.stringify(obj1[key]) !== JSON.stringify(obj2[key])) {
|
||||
changes[key] = {
|
||||
before: obj1[key],
|
||||
after: obj2[key]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,36 @@
|
||||
*/
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { TelemetryEvent, WorkflowTelemetry, TELEMETRY_CONFIG, TelemetryMetrics } from './telemetry-types';
|
||||
import { TelemetryEvent, WorkflowTelemetry, WorkflowMutationRecord, TELEMETRY_CONFIG, TelemetryMetrics } from './telemetry-types';
|
||||
import { TelemetryError, TelemetryErrorType, TelemetryCircuitBreaker } from './telemetry-error';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Convert camelCase object keys to snake_case
|
||||
* Needed because Supabase PostgREST doesn't auto-convert
|
||||
*/
|
||||
function toSnakeCase(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (Array.isArray(obj)) return obj.map(toSnakeCase);
|
||||
if (typeof obj !== 'object') return obj;
|
||||
|
||||
const result: any = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
// Convert camelCase to snake_case
|
||||
const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||
// Recursively convert nested objects
|
||||
result[snakeKey] = toSnakeCase(obj[key]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class TelemetryBatchProcessor {
|
||||
private flushTimer?: NodeJS.Timeout;
|
||||
private isFlushingEvents: boolean = false;
|
||||
private isFlushingWorkflows: boolean = false;
|
||||
private isFlushingMutations: boolean = false;
|
||||
private circuitBreaker: TelemetryCircuitBreaker;
|
||||
private metrics: TelemetryMetrics = {
|
||||
eventsTracked: 0,
|
||||
@@ -23,7 +45,7 @@ export class TelemetryBatchProcessor {
|
||||
rateLimitHits: 0
|
||||
};
|
||||
private flushTimes: number[] = [];
|
||||
private deadLetterQueue: (TelemetryEvent | WorkflowTelemetry)[] = [];
|
||||
private deadLetterQueue: (TelemetryEvent | WorkflowTelemetry | WorkflowMutationRecord)[] = [];
|
||||
private readonly maxDeadLetterSize = 100;
|
||||
|
||||
constructor(
|
||||
@@ -76,15 +98,15 @@ export class TelemetryBatchProcessor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush events and workflows to Supabase
|
||||
* Flush events, workflows, and mutations to Supabase
|
||||
*/
|
||||
async flush(events?: TelemetryEvent[], workflows?: WorkflowTelemetry[]): Promise<void> {
|
||||
async flush(events?: TelemetryEvent[], workflows?: WorkflowTelemetry[], mutations?: WorkflowMutationRecord[]): Promise<void> {
|
||||
if (!this.isEnabled() || !this.supabase) return;
|
||||
|
||||
// Check circuit breaker
|
||||
if (!this.circuitBreaker.shouldAllow()) {
|
||||
logger.debug('Circuit breaker open - skipping flush');
|
||||
this.metrics.eventsDropped += (events?.length || 0) + (workflows?.length || 0);
|
||||
this.metrics.eventsDropped += (events?.length || 0) + (workflows?.length || 0) + (mutations?.length || 0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,6 +123,11 @@ export class TelemetryBatchProcessor {
|
||||
hasErrors = !(await this.flushWorkflows(workflows)) || hasErrors;
|
||||
}
|
||||
|
||||
// Flush mutations if provided
|
||||
if (mutations && mutations.length > 0) {
|
||||
hasErrors = !(await this.flushMutations(mutations)) || hasErrors;
|
||||
}
|
||||
|
||||
// Record flush time
|
||||
const flushTime = Date.now() - startTime;
|
||||
this.recordFlushTime(flushTime);
|
||||
@@ -224,6 +251,71 @@ export class TelemetryBatchProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush workflow mutations with batching
|
||||
*/
|
||||
private async flushMutations(mutations: WorkflowMutationRecord[]): Promise<boolean> {
|
||||
if (this.isFlushingMutations || mutations.length === 0) return true;
|
||||
|
||||
this.isFlushingMutations = true;
|
||||
|
||||
try {
|
||||
// Batch mutations
|
||||
const batches = this.createBatches(mutations, TELEMETRY_CONFIG.MAX_BATCH_SIZE);
|
||||
|
||||
for (const batch of batches) {
|
||||
const result = await this.executeWithRetry(async () => {
|
||||
// Convert camelCase to snake_case for Supabase
|
||||
const snakeCaseBatch = batch.map(mutation => toSnakeCase(mutation));
|
||||
|
||||
const { error } = await this.supabase!
|
||||
.from('workflow_mutations')
|
||||
.insert(snakeCaseBatch);
|
||||
|
||||
if (error) {
|
||||
// Enhanced error logging for mutation flushes
|
||||
logger.error('Mutation insert error details:', {
|
||||
code: (error as any).code,
|
||||
message: (error as any).message,
|
||||
details: (error as any).details,
|
||||
hint: (error as any).hint,
|
||||
fullError: String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.debug(`Flushed batch of ${batch.length} workflow mutations`);
|
||||
return true;
|
||||
}, 'Flush workflow mutations');
|
||||
|
||||
if (result) {
|
||||
this.metrics.eventsTracked += batch.length;
|
||||
this.metrics.batchesSent++;
|
||||
} else {
|
||||
this.metrics.eventsFailed += batch.length;
|
||||
this.metrics.batchesFailed++;
|
||||
this.addToDeadLetterQueue(batch);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to flush mutations with details:', {
|
||||
errorMsg: error instanceof Error ? error.message : String(error),
|
||||
errorType: error instanceof Error ? error.constructor.name : typeof error
|
||||
});
|
||||
throw new TelemetryError(
|
||||
TelemetryErrorType.NETWORK_ERROR,
|
||||
'Failed to flush workflow mutations',
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
this.isFlushingMutations = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with exponential backoff retry
|
||||
*/
|
||||
@@ -305,7 +397,7 @@ export class TelemetryBatchProcessor {
|
||||
/**
|
||||
* Add failed items to dead letter queue
|
||||
*/
|
||||
private addToDeadLetterQueue(items: (TelemetryEvent | WorkflowTelemetry)[]): void {
|
||||
private addToDeadLetterQueue(items: (TelemetryEvent | WorkflowTelemetry | WorkflowMutationRecord)[]): void {
|
||||
for (const item of items) {
|
||||
this.deadLetterQueue.push(item);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Now uses shared sanitization utilities to avoid code duplication
|
||||
*/
|
||||
|
||||
import { TelemetryEvent, WorkflowTelemetry } from './telemetry-types';
|
||||
import { TelemetryEvent, WorkflowTelemetry, WorkflowMutationRecord } from './telemetry-types';
|
||||
import { WorkflowSanitizer } from './workflow-sanitizer';
|
||||
import { TelemetryRateLimiter } from './rate-limiter';
|
||||
import { TelemetryEventValidator } from './event-validator';
|
||||
@@ -19,6 +19,7 @@ export class TelemetryEventTracker {
|
||||
private validator: TelemetryEventValidator;
|
||||
private eventQueue: TelemetryEvent[] = [];
|
||||
private workflowQueue: WorkflowTelemetry[] = [];
|
||||
private mutationQueue: WorkflowMutationRecord[] = [];
|
||||
private previousTool?: string;
|
||||
private previousToolTimestamp: number = 0;
|
||||
private performanceMetrics: Map<string, number[]> = new Map();
|
||||
@@ -325,6 +326,13 @@ export class TelemetryEventTracker {
|
||||
return [...this.workflowQueue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queued mutations
|
||||
*/
|
||||
getMutationQueue(): WorkflowMutationRecord[] {
|
||||
return [...this.mutationQueue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear event queue
|
||||
*/
|
||||
@@ -339,6 +347,28 @@ export class TelemetryEventTracker {
|
||||
this.workflowQueue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear mutation queue
|
||||
*/
|
||||
clearMutationQueue(): void {
|
||||
this.mutationQueue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue mutation for batch processing
|
||||
*/
|
||||
enqueueMutation(mutation: WorkflowMutationRecord): void {
|
||||
if (!this.isEnabled()) return;
|
||||
this.mutationQueue.push(mutation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mutation queue size
|
||||
*/
|
||||
getMutationQueueSize(): number {
|
||||
return this.mutationQueue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tracking statistics
|
||||
*/
|
||||
@@ -348,6 +378,7 @@ export class TelemetryEventTracker {
|
||||
validator: this.validator.getStats(),
|
||||
eventQueueSize: this.eventQueue.length,
|
||||
workflowQueueSize: this.workflowQueue.length,
|
||||
mutationQueueSize: this.mutationQueue.length,
|
||||
performanceMetrics: this.getPerformanceStats()
|
||||
};
|
||||
}
|
||||
|
||||
243
src/telemetry/intent-classifier.ts
Normal file
243
src/telemetry/intent-classifier.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Intent classifier for workflow mutations
|
||||
* Analyzes operations to determine the intent/pattern of the mutation
|
||||
*/
|
||||
|
||||
import { DiffOperation } from '../types/workflow-diff.js';
|
||||
import { IntentClassification } from './mutation-types.js';
|
||||
|
||||
/**
|
||||
* Classifies the intent of a workflow mutation based on operations performed
|
||||
*/
|
||||
export class IntentClassifier {
|
||||
/**
|
||||
* Classify mutation intent from operations and optional user intent text
|
||||
*/
|
||||
classify(operations: DiffOperation[], userIntent?: string): IntentClassification {
|
||||
if (operations.length === 0) {
|
||||
return IntentClassification.UNKNOWN;
|
||||
}
|
||||
|
||||
// First, try to classify from user intent text if provided
|
||||
if (userIntent) {
|
||||
const textClassification = this.classifyFromText(userIntent);
|
||||
if (textClassification !== IntentClassification.UNKNOWN) {
|
||||
return textClassification;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to operation pattern analysis
|
||||
return this.classifyFromOperations(operations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify from user intent text using keyword matching
|
||||
*/
|
||||
private classifyFromText(intent: string): IntentClassification {
|
||||
const lowerIntent = intent.toLowerCase();
|
||||
|
||||
// Fix validation errors
|
||||
if (
|
||||
lowerIntent.includes('fix') ||
|
||||
lowerIntent.includes('resolve') ||
|
||||
lowerIntent.includes('correct') ||
|
||||
lowerIntent.includes('repair') ||
|
||||
lowerIntent.includes('error')
|
||||
) {
|
||||
return IntentClassification.FIX_VALIDATION;
|
||||
}
|
||||
|
||||
// Add new functionality
|
||||
if (
|
||||
lowerIntent.includes('add') ||
|
||||
lowerIntent.includes('create') ||
|
||||
lowerIntent.includes('insert') ||
|
||||
lowerIntent.includes('new node')
|
||||
) {
|
||||
return IntentClassification.ADD_FUNCTIONALITY;
|
||||
}
|
||||
|
||||
// Modify configuration
|
||||
if (
|
||||
lowerIntent.includes('update') ||
|
||||
lowerIntent.includes('change') ||
|
||||
lowerIntent.includes('modify') ||
|
||||
lowerIntent.includes('configure') ||
|
||||
lowerIntent.includes('set')
|
||||
) {
|
||||
return IntentClassification.MODIFY_CONFIGURATION;
|
||||
}
|
||||
|
||||
// Rewire logic
|
||||
if (
|
||||
lowerIntent.includes('connect') ||
|
||||
lowerIntent.includes('reconnect') ||
|
||||
lowerIntent.includes('rewire') ||
|
||||
lowerIntent.includes('reroute') ||
|
||||
lowerIntent.includes('link')
|
||||
) {
|
||||
return IntentClassification.REWIRE_LOGIC;
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (
|
||||
lowerIntent.includes('remove') ||
|
||||
lowerIntent.includes('delete') ||
|
||||
lowerIntent.includes('clean') ||
|
||||
lowerIntent.includes('disable')
|
||||
) {
|
||||
return IntentClassification.CLEANUP;
|
||||
}
|
||||
|
||||
return IntentClassification.UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify from operation patterns
|
||||
*/
|
||||
private classifyFromOperations(operations: DiffOperation[]): IntentClassification {
|
||||
const opTypes = operations.map((op) => op.type);
|
||||
const opTypeSet = new Set(opTypes);
|
||||
|
||||
// Pattern: Adding nodes and connections (add functionality)
|
||||
if (opTypeSet.has('addNode') && opTypeSet.has('addConnection')) {
|
||||
return IntentClassification.ADD_FUNCTIONALITY;
|
||||
}
|
||||
|
||||
// Pattern: Only adding nodes (add functionality)
|
||||
if (opTypeSet.has('addNode') && !opTypeSet.has('removeNode')) {
|
||||
return IntentClassification.ADD_FUNCTIONALITY;
|
||||
}
|
||||
|
||||
// Pattern: Removing nodes or connections (cleanup)
|
||||
if (opTypeSet.has('removeNode') || opTypeSet.has('removeConnection')) {
|
||||
return IntentClassification.CLEANUP;
|
||||
}
|
||||
|
||||
// Pattern: Disabling nodes (cleanup)
|
||||
if (opTypeSet.has('disableNode')) {
|
||||
return IntentClassification.CLEANUP;
|
||||
}
|
||||
|
||||
// Pattern: Rewiring connections
|
||||
if (
|
||||
opTypeSet.has('rewireConnection') ||
|
||||
opTypeSet.has('replaceConnections') ||
|
||||
(opTypeSet.has('addConnection') && opTypeSet.has('removeConnection'))
|
||||
) {
|
||||
return IntentClassification.REWIRE_LOGIC;
|
||||
}
|
||||
|
||||
// Pattern: Only updating nodes (modify configuration)
|
||||
if (opTypeSet.has('updateNode') && opTypes.every((t) => t === 'updateNode')) {
|
||||
return IntentClassification.MODIFY_CONFIGURATION;
|
||||
}
|
||||
|
||||
// Pattern: Updating settings or metadata (modify configuration)
|
||||
if (
|
||||
opTypeSet.has('updateSettings') ||
|
||||
opTypeSet.has('updateName') ||
|
||||
opTypeSet.has('addTag') ||
|
||||
opTypeSet.has('removeTag')
|
||||
) {
|
||||
return IntentClassification.MODIFY_CONFIGURATION;
|
||||
}
|
||||
|
||||
// Pattern: Mix of updates with some additions/removals (modify configuration)
|
||||
if (opTypeSet.has('updateNode')) {
|
||||
return IntentClassification.MODIFY_CONFIGURATION;
|
||||
}
|
||||
|
||||
// Pattern: Moving nodes (modify configuration)
|
||||
if (opTypeSet.has('moveNode')) {
|
||||
return IntentClassification.MODIFY_CONFIGURATION;
|
||||
}
|
||||
|
||||
// Pattern: Enabling nodes (could be fixing)
|
||||
if (opTypeSet.has('enableNode')) {
|
||||
return IntentClassification.FIX_VALIDATION;
|
||||
}
|
||||
|
||||
// Pattern: Clean stale connections (cleanup)
|
||||
if (opTypeSet.has('cleanStaleConnections')) {
|
||||
return IntentClassification.CLEANUP;
|
||||
}
|
||||
|
||||
return IntentClassification.UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confidence score for classification (0-1)
|
||||
* Higher score means more confident in the classification
|
||||
*/
|
||||
getConfidence(
|
||||
classification: IntentClassification,
|
||||
operations: DiffOperation[],
|
||||
userIntent?: string
|
||||
): number {
|
||||
// High confidence if user intent matches operation pattern
|
||||
if (userIntent && this.classifyFromText(userIntent) === classification) {
|
||||
return 0.9;
|
||||
}
|
||||
|
||||
// Medium-high confidence for clear operation patterns
|
||||
if (classification !== IntentClassification.UNKNOWN) {
|
||||
const opTypes = new Set(operations.map((op) => op.type));
|
||||
|
||||
// Very clear patterns get high confidence
|
||||
if (
|
||||
classification === IntentClassification.ADD_FUNCTIONALITY &&
|
||||
opTypes.has('addNode')
|
||||
) {
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
if (
|
||||
classification === IntentClassification.CLEANUP &&
|
||||
(opTypes.has('removeNode') || opTypes.has('removeConnection'))
|
||||
) {
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
if (
|
||||
classification === IntentClassification.REWIRE_LOGIC &&
|
||||
opTypes.has('rewireConnection')
|
||||
) {
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
// Other patterns get medium confidence
|
||||
return 0.6;
|
||||
}
|
||||
|
||||
// Low confidence for unknown classification
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description of the classification
|
||||
*/
|
||||
getDescription(classification: IntentClassification): string {
|
||||
switch (classification) {
|
||||
case IntentClassification.ADD_FUNCTIONALITY:
|
||||
return 'Adding new nodes or functionality to the workflow';
|
||||
case IntentClassification.MODIFY_CONFIGURATION:
|
||||
return 'Modifying configuration of existing nodes';
|
||||
case IntentClassification.REWIRE_LOGIC:
|
||||
return 'Changing workflow execution flow by rewiring connections';
|
||||
case IntentClassification.FIX_VALIDATION:
|
||||
return 'Fixing validation errors or issues';
|
||||
case IntentClassification.CLEANUP:
|
||||
return 'Removing or disabling nodes and connections';
|
||||
case IntentClassification.UNKNOWN:
|
||||
return 'Unknown or complex mutation pattern';
|
||||
default:
|
||||
return 'Unclassified mutation';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance for easy access
|
||||
*/
|
||||
export const intentClassifier = new IntentClassifier();
|
||||
187
src/telemetry/intent-sanitizer.ts
Normal file
187
src/telemetry/intent-sanitizer.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Intent sanitizer for removing PII from user intent strings
|
||||
* Ensures privacy by masking sensitive information
|
||||
*/
|
||||
|
||||
/**
|
||||
* Patterns for detecting and removing PII
|
||||
*/
|
||||
const PII_PATTERNS = {
|
||||
// Email addresses
|
||||
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/gi,
|
||||
|
||||
// URLs with domains
|
||||
url: /https?:\/\/[^\s]+/gi,
|
||||
|
||||
// IP addresses
|
||||
ip: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
||||
|
||||
// Phone numbers (various formats)
|
||||
phone: /\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
|
||||
|
||||
// Credit card-like numbers (groups of 4 digits)
|
||||
creditCard: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g,
|
||||
|
||||
// API keys and tokens (long alphanumeric strings)
|
||||
apiKey: /\b[A-Za-z0-9_-]{32,}\b/g,
|
||||
|
||||
// UUIDs
|
||||
uuid: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
|
||||
|
||||
// File paths (Unix and Windows)
|
||||
filePath: /(?:\/[\w.-]+)+\/?|(?:[A-Z]:\\(?:[\w.-]+\\)*[\w.-]+)/g,
|
||||
|
||||
// Potential passwords or secrets (common patterns)
|
||||
secret: /\b(?:password|passwd|pwd|secret|token|key)[:=\s]+[^\s]+/gi,
|
||||
};
|
||||
|
||||
/**
|
||||
* Company/organization name patterns to anonymize
|
||||
* These are common patterns that might appear in workflow intents
|
||||
*/
|
||||
const COMPANY_PATTERNS = {
|
||||
// Company suffixes
|
||||
companySuffix: /\b\w+(?:\s+(?:Inc|LLC|Corp|Corporation|Ltd|Limited|GmbH|AG)\.?)\b/gi,
|
||||
|
||||
// Common business terms that might indicate company names
|
||||
businessContext: /\b(?:company|organization|client|customer)\s+(?:named?|called)\s+\w+/gi,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes user intent by removing PII and sensitive information
|
||||
*/
|
||||
export class IntentSanitizer {
|
||||
/**
|
||||
* Sanitize user intent string
|
||||
*/
|
||||
sanitize(intent: string): string {
|
||||
if (!intent) {
|
||||
return intent;
|
||||
}
|
||||
|
||||
let sanitized = intent;
|
||||
|
||||
// Remove email addresses
|
||||
sanitized = sanitized.replace(PII_PATTERNS.email, '[EMAIL]');
|
||||
|
||||
// Remove URLs
|
||||
sanitized = sanitized.replace(PII_PATTERNS.url, '[URL]');
|
||||
|
||||
// Remove IP addresses
|
||||
sanitized = sanitized.replace(PII_PATTERNS.ip, '[IP_ADDRESS]');
|
||||
|
||||
// Remove phone numbers
|
||||
sanitized = sanitized.replace(PII_PATTERNS.phone, '[PHONE]');
|
||||
|
||||
// Remove credit card numbers
|
||||
sanitized = sanitized.replace(PII_PATTERNS.creditCard, '[CARD_NUMBER]');
|
||||
|
||||
// Remove API keys and long tokens
|
||||
sanitized = sanitized.replace(PII_PATTERNS.apiKey, '[API_KEY]');
|
||||
|
||||
// Remove UUIDs
|
||||
sanitized = sanitized.replace(PII_PATTERNS.uuid, '[UUID]');
|
||||
|
||||
// Remove file paths
|
||||
sanitized = sanitized.replace(PII_PATTERNS.filePath, '[FILE_PATH]');
|
||||
|
||||
// Remove secrets/passwords
|
||||
sanitized = sanitized.replace(PII_PATTERNS.secret, '[SECRET]');
|
||||
|
||||
// Anonymize company names
|
||||
sanitized = sanitized.replace(COMPANY_PATTERNS.companySuffix, '[COMPANY]');
|
||||
sanitized = sanitized.replace(COMPANY_PATTERNS.businessContext, '[COMPANY_CONTEXT]');
|
||||
|
||||
// Clean up multiple spaces
|
||||
sanitized = sanitized.replace(/\s{2,}/g, ' ').trim();
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if intent contains potential PII
|
||||
*/
|
||||
containsPII(intent: string): boolean {
|
||||
if (!intent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(PII_PATTERNS).some((pattern) => pattern.test(intent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of PII types detected in the intent
|
||||
*/
|
||||
detectPIITypes(intent: string): string[] {
|
||||
if (!intent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const detected: string[] = [];
|
||||
|
||||
if (PII_PATTERNS.email.test(intent)) detected.push('email');
|
||||
if (PII_PATTERNS.url.test(intent)) detected.push('url');
|
||||
if (PII_PATTERNS.ip.test(intent)) detected.push('ip_address');
|
||||
if (PII_PATTERNS.phone.test(intent)) detected.push('phone');
|
||||
if (PII_PATTERNS.creditCard.test(intent)) detected.push('credit_card');
|
||||
if (PII_PATTERNS.apiKey.test(intent)) detected.push('api_key');
|
||||
if (PII_PATTERNS.uuid.test(intent)) detected.push('uuid');
|
||||
if (PII_PATTERNS.filePath.test(intent)) detected.push('file_path');
|
||||
if (PII_PATTERNS.secret.test(intent)) detected.push('secret');
|
||||
|
||||
// Reset lastIndex for global regexes
|
||||
Object.values(PII_PATTERNS).forEach((pattern) => {
|
||||
pattern.lastIndex = 0;
|
||||
});
|
||||
|
||||
return detected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate intent to maximum length while preserving meaning
|
||||
*/
|
||||
truncate(intent: string, maxLength: number = 1000): string {
|
||||
if (!intent || intent.length <= maxLength) {
|
||||
return intent;
|
||||
}
|
||||
|
||||
// Try to truncate at sentence boundary
|
||||
const truncated = intent.substring(0, maxLength);
|
||||
const lastSentence = truncated.lastIndexOf('.');
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
|
||||
if (lastSentence > maxLength * 0.8) {
|
||||
return truncated.substring(0, lastSentence + 1);
|
||||
} else if (lastSpace > maxLength * 0.9) {
|
||||
return truncated.substring(0, lastSpace) + '...';
|
||||
}
|
||||
|
||||
return truncated + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate intent is safe for telemetry
|
||||
*/
|
||||
isSafeForTelemetry(intent: string): boolean {
|
||||
if (!intent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check length
|
||||
if (intent.length > 5000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for null bytes or control characters
|
||||
if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(intent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance for easy access
|
||||
*/
|
||||
export const intentSanitizer = new IntentSanitizer();
|
||||
283
src/telemetry/mutation-tracker.ts
Normal file
283
src/telemetry/mutation-tracker.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Core mutation tracker for workflow transformations
|
||||
* Coordinates validation, classification, and metric calculation
|
||||
*/
|
||||
|
||||
import { DiffOperation } from '../types/workflow-diff.js';
|
||||
import {
|
||||
WorkflowMutationData,
|
||||
WorkflowMutationRecord,
|
||||
MutationChangeMetrics,
|
||||
MutationValidationMetrics,
|
||||
IntentClassification,
|
||||
} from './mutation-types.js';
|
||||
import { intentClassifier } from './intent-classifier.js';
|
||||
import { mutationValidator } from './mutation-validator.js';
|
||||
import { intentSanitizer } from './intent-sanitizer.js';
|
||||
import { WorkflowSanitizer } from './workflow-sanitizer.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Tracks workflow mutations and prepares data for telemetry
|
||||
*/
|
||||
export class MutationTracker {
|
||||
private recentMutations: Array<{
|
||||
hashBefore: string;
|
||||
hashAfter: string;
|
||||
operations: DiffOperation[];
|
||||
}> = [];
|
||||
|
||||
private readonly RECENT_MUTATIONS_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Process and prepare mutation data for tracking
|
||||
*/
|
||||
async processMutation(data: WorkflowMutationData, userId: string): Promise<WorkflowMutationRecord | null> {
|
||||
try {
|
||||
// Validate data quality
|
||||
if (!this.validateMutationData(data)) {
|
||||
logger.debug('Mutation data validation failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sanitize workflows to remove credentials and sensitive data
|
||||
const workflowBefore = WorkflowSanitizer.sanitizeWorkflowRaw(data.workflowBefore);
|
||||
const workflowAfter = WorkflowSanitizer.sanitizeWorkflowRaw(data.workflowAfter);
|
||||
|
||||
// Sanitize user intent
|
||||
const sanitizedIntent = intentSanitizer.sanitize(data.userIntent);
|
||||
|
||||
// Check if should be excluded
|
||||
if (mutationValidator.shouldExclude(data)) {
|
||||
logger.debug('Mutation excluded from tracking based on quality criteria');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (
|
||||
mutationValidator.isDuplicate(
|
||||
workflowBefore,
|
||||
workflowAfter,
|
||||
data.operations,
|
||||
this.recentMutations
|
||||
)
|
||||
) {
|
||||
logger.debug('Duplicate mutation detected, skipping tracking');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate hashes
|
||||
const hashBefore = mutationValidator.hashWorkflow(workflowBefore);
|
||||
const hashAfter = mutationValidator.hashWorkflow(workflowAfter);
|
||||
|
||||
// Generate structural hashes for cross-referencing with telemetry_workflows
|
||||
const structureHashBefore = WorkflowSanitizer.generateWorkflowHash(workflowBefore);
|
||||
const structureHashAfter = WorkflowSanitizer.generateWorkflowHash(workflowAfter);
|
||||
|
||||
// Classify intent
|
||||
const intentClassification = intentClassifier.classify(data.operations, sanitizedIntent);
|
||||
|
||||
// Calculate metrics
|
||||
const changeMetrics = this.calculateChangeMetrics(data.operations);
|
||||
const validationMetrics = this.calculateValidationMetrics(
|
||||
data.validationBefore,
|
||||
data.validationAfter
|
||||
);
|
||||
|
||||
// Create mutation record
|
||||
const record: WorkflowMutationRecord = {
|
||||
userId,
|
||||
sessionId: data.sessionId,
|
||||
workflowBefore,
|
||||
workflowAfter,
|
||||
workflowHashBefore: hashBefore,
|
||||
workflowHashAfter: hashAfter,
|
||||
workflowStructureHashBefore: structureHashBefore,
|
||||
workflowStructureHashAfter: structureHashAfter,
|
||||
userIntent: sanitizedIntent,
|
||||
intentClassification,
|
||||
toolName: data.toolName,
|
||||
operations: data.operations,
|
||||
operationCount: data.operations.length,
|
||||
operationTypes: this.extractOperationTypes(data.operations),
|
||||
validationBefore: data.validationBefore,
|
||||
validationAfter: data.validationAfter,
|
||||
...validationMetrics,
|
||||
...changeMetrics,
|
||||
mutationSuccess: data.mutationSuccess,
|
||||
mutationError: data.mutationError,
|
||||
durationMs: data.durationMs,
|
||||
};
|
||||
|
||||
// Store in recent mutations for deduplication
|
||||
this.addToRecentMutations(hashBefore, hashAfter, data.operations);
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
logger.error('Error processing mutation:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate mutation data
|
||||
*/
|
||||
private validateMutationData(data: WorkflowMutationData): boolean {
|
||||
const validationResult = mutationValidator.validate(data);
|
||||
|
||||
if (!validationResult.valid) {
|
||||
logger.warn('Mutation data validation failed:', validationResult.errors);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (validationResult.warnings.length > 0) {
|
||||
logger.debug('Mutation data validation warnings:', validationResult.warnings);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate change metrics from operations
|
||||
*/
|
||||
private calculateChangeMetrics(operations: DiffOperation[]): MutationChangeMetrics {
|
||||
const metrics: MutationChangeMetrics = {
|
||||
nodesAdded: 0,
|
||||
nodesRemoved: 0,
|
||||
nodesModified: 0,
|
||||
connectionsAdded: 0,
|
||||
connectionsRemoved: 0,
|
||||
propertiesChanged: 0,
|
||||
};
|
||||
|
||||
for (const op of operations) {
|
||||
switch (op.type) {
|
||||
case 'addNode':
|
||||
metrics.nodesAdded++;
|
||||
break;
|
||||
case 'removeNode':
|
||||
metrics.nodesRemoved++;
|
||||
break;
|
||||
case 'updateNode':
|
||||
metrics.nodesModified++;
|
||||
if ('updates' in op && op.updates) {
|
||||
metrics.propertiesChanged += Object.keys(op.updates as any).length;
|
||||
}
|
||||
break;
|
||||
case 'addConnection':
|
||||
metrics.connectionsAdded++;
|
||||
break;
|
||||
case 'removeConnection':
|
||||
metrics.connectionsRemoved++;
|
||||
break;
|
||||
case 'rewireConnection':
|
||||
// Rewiring is effectively removing + adding
|
||||
metrics.connectionsRemoved++;
|
||||
metrics.connectionsAdded++;
|
||||
break;
|
||||
case 'replaceConnections':
|
||||
// Count how many connections are being replaced
|
||||
if ('connections' in op && op.connections) {
|
||||
metrics.connectionsRemoved++;
|
||||
metrics.connectionsAdded++;
|
||||
}
|
||||
break;
|
||||
case 'updateSettings':
|
||||
if ('settings' in op && op.settings) {
|
||||
metrics.propertiesChanged += Object.keys(op.settings as any).length;
|
||||
}
|
||||
break;
|
||||
case 'moveNode':
|
||||
case 'enableNode':
|
||||
case 'disableNode':
|
||||
case 'updateName':
|
||||
case 'addTag':
|
||||
case 'removeTag':
|
||||
case 'activateWorkflow':
|
||||
case 'deactivateWorkflow':
|
||||
case 'cleanStaleConnections':
|
||||
// These don't directly affect node/connection counts
|
||||
// but count as property changes
|
||||
metrics.propertiesChanged++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculate validation improvement metrics
|
||||
*/
|
||||
private calculateValidationMetrics(
|
||||
validationBefore: any,
|
||||
validationAfter: any
|
||||
): MutationValidationMetrics {
|
||||
// If validation data is missing, return nulls
|
||||
if (!validationBefore || !validationAfter) {
|
||||
return {
|
||||
validationImproved: null,
|
||||
errorsResolved: 0,
|
||||
errorsIntroduced: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const errorsBefore = validationBefore.errors?.length || 0;
|
||||
const errorsAfter = validationAfter.errors?.length || 0;
|
||||
|
||||
const errorsResolved = Math.max(0, errorsBefore - errorsAfter);
|
||||
const errorsIntroduced = Math.max(0, errorsAfter - errorsBefore);
|
||||
|
||||
const validationImproved = errorsBefore > errorsAfter;
|
||||
|
||||
return {
|
||||
validationImproved,
|
||||
errorsResolved,
|
||||
errorsIntroduced,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique operation types from operations
|
||||
*/
|
||||
private extractOperationTypes(operations: DiffOperation[]): string[] {
|
||||
const types = new Set(operations.map((op) => op.type));
|
||||
return Array.from(types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add mutation to recent list for deduplication
|
||||
*/
|
||||
private addToRecentMutations(
|
||||
hashBefore: string,
|
||||
hashAfter: string,
|
||||
operations: DiffOperation[]
|
||||
): void {
|
||||
this.recentMutations.push({ hashBefore, hashAfter, operations });
|
||||
|
||||
// Keep only recent mutations
|
||||
if (this.recentMutations.length > this.RECENT_MUTATIONS_LIMIT) {
|
||||
this.recentMutations.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear recent mutations (useful for testing)
|
||||
*/
|
||||
clearRecentMutations(): void {
|
||||
this.recentMutations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about tracked mutations
|
||||
*/
|
||||
getRecentMutationsCount(): number {
|
||||
return this.recentMutations.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance for easy access
|
||||
*/
|
||||
export const mutationTracker = new MutationTracker();
|
||||
160
src/telemetry/mutation-types.ts
Normal file
160
src/telemetry/mutation-types.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Types and interfaces for workflow mutation tracking
|
||||
* Purpose: Track workflow transformations to improve partial updates tooling
|
||||
*/
|
||||
|
||||
import { DiffOperation } from '../types/workflow-diff.js';
|
||||
|
||||
/**
|
||||
* Intent classification for workflow mutations
|
||||
*/
|
||||
export enum IntentClassification {
|
||||
ADD_FUNCTIONALITY = 'add_functionality',
|
||||
MODIFY_CONFIGURATION = 'modify_configuration',
|
||||
REWIRE_LOGIC = 'rewire_logic',
|
||||
FIX_VALIDATION = 'fix_validation',
|
||||
CLEANUP = 'cleanup',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool names that perform workflow mutations
|
||||
*/
|
||||
export enum MutationToolName {
|
||||
UPDATE_PARTIAL = 'n8n_update_partial_workflow',
|
||||
UPDATE_FULL = 'n8n_update_full_workflow',
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result structure
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: Array<{
|
||||
type: string;
|
||||
message: string;
|
||||
severity?: string;
|
||||
location?: string;
|
||||
}>;
|
||||
warnings?: Array<{
|
||||
type: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change metrics calculated from workflow mutation
|
||||
*/
|
||||
export interface MutationChangeMetrics {
|
||||
nodesAdded: number;
|
||||
nodesRemoved: number;
|
||||
nodesModified: number;
|
||||
connectionsAdded: number;
|
||||
connectionsRemoved: number;
|
||||
propertiesChanged: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation improvement metrics
|
||||
*/
|
||||
export interface MutationValidationMetrics {
|
||||
validationImproved: boolean | null;
|
||||
errorsResolved: number;
|
||||
errorsIntroduced: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input data for tracking a workflow mutation
|
||||
*/
|
||||
export interface WorkflowMutationData {
|
||||
sessionId: string;
|
||||
toolName: MutationToolName;
|
||||
userIntent: string;
|
||||
operations: DiffOperation[];
|
||||
workflowBefore: any;
|
||||
workflowAfter: any;
|
||||
validationBefore?: ValidationResult;
|
||||
validationAfter?: ValidationResult;
|
||||
mutationSuccess: boolean;
|
||||
mutationError?: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete mutation record for database storage
|
||||
*/
|
||||
export interface WorkflowMutationRecord {
|
||||
id?: string;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
workflowBefore: any;
|
||||
workflowAfter: any;
|
||||
workflowHashBefore: string;
|
||||
workflowHashAfter: string;
|
||||
/** Structural hash (nodeTypes + connections) for cross-referencing with telemetry_workflows */
|
||||
workflowStructureHashBefore?: string;
|
||||
/** Structural hash (nodeTypes + connections) for cross-referencing with telemetry_workflows */
|
||||
workflowStructureHashAfter?: string;
|
||||
/** Computed field: true if mutation executed successfully, improved validation, and has known intent */
|
||||
isTrulySuccessful?: boolean;
|
||||
userIntent: string;
|
||||
intentClassification: IntentClassification;
|
||||
toolName: MutationToolName;
|
||||
operations: DiffOperation[];
|
||||
operationCount: number;
|
||||
operationTypes: string[];
|
||||
validationBefore?: ValidationResult;
|
||||
validationAfter?: ValidationResult;
|
||||
validationImproved: boolean | null;
|
||||
errorsResolved: number;
|
||||
errorsIntroduced: number;
|
||||
nodesAdded: number;
|
||||
nodesRemoved: number;
|
||||
nodesModified: number;
|
||||
connectionsAdded: number;
|
||||
connectionsRemoved: number;
|
||||
propertiesChanged: number;
|
||||
mutationSuccess: boolean;
|
||||
mutationError?: string;
|
||||
durationMs: number;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for mutation tracking
|
||||
*/
|
||||
export interface MutationTrackingOptions {
|
||||
/** Whether to track this mutation (default: true) */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Maximum workflow size in KB to track (default: 500) */
|
||||
maxWorkflowSizeKb?: number;
|
||||
|
||||
/** Whether to validate data quality before tracking (default: true) */
|
||||
validateQuality?: boolean;
|
||||
|
||||
/** Whether to sanitize workflows for PII (default: true) */
|
||||
sanitize?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation tracking statistics for monitoring
|
||||
*/
|
||||
export interface MutationTrackingStats {
|
||||
totalMutationsTracked: number;
|
||||
successfulMutations: number;
|
||||
failedMutations: number;
|
||||
mutationsWithValidationImprovement: number;
|
||||
averageDurationMs: number;
|
||||
intentClassificationBreakdown: Record<IntentClassification, number>;
|
||||
operationTypeBreakdown: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data quality validation result
|
||||
*/
|
||||
export interface MutationDataQualityResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
237
src/telemetry/mutation-validator.ts
Normal file
237
src/telemetry/mutation-validator.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Data quality validator for workflow mutations
|
||||
* Ensures mutation data meets quality standards before tracking
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
WorkflowMutationData,
|
||||
MutationDataQualityResult,
|
||||
MutationTrackingOptions,
|
||||
} from './mutation-types.js';
|
||||
|
||||
/**
|
||||
* Default options for mutation tracking
|
||||
*/
|
||||
export const DEFAULT_MUTATION_TRACKING_OPTIONS: Required<MutationTrackingOptions> = {
|
||||
enabled: true,
|
||||
maxWorkflowSizeKb: 500,
|
||||
validateQuality: true,
|
||||
sanitize: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates workflow mutation data quality
|
||||
*/
|
||||
export class MutationValidator {
|
||||
private options: Required<MutationTrackingOptions>;
|
||||
|
||||
constructor(options: MutationTrackingOptions = {}) {
|
||||
this.options = { ...DEFAULT_MUTATION_TRACKING_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate mutation data quality
|
||||
*/
|
||||
validate(data: WorkflowMutationData): MutationDataQualityResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check workflow structure
|
||||
if (!this.isValidWorkflow(data.workflowBefore)) {
|
||||
errors.push('Invalid workflow_before structure');
|
||||
}
|
||||
|
||||
if (!this.isValidWorkflow(data.workflowAfter)) {
|
||||
errors.push('Invalid workflow_after structure');
|
||||
}
|
||||
|
||||
// Check workflow size
|
||||
const beforeSizeKb = this.getWorkflowSizeKb(data.workflowBefore);
|
||||
const afterSizeKb = this.getWorkflowSizeKb(data.workflowAfter);
|
||||
|
||||
if (beforeSizeKb > this.options.maxWorkflowSizeKb) {
|
||||
errors.push(
|
||||
`workflow_before size (${beforeSizeKb}KB) exceeds maximum (${this.options.maxWorkflowSizeKb}KB)`
|
||||
);
|
||||
}
|
||||
|
||||
if (afterSizeKb > this.options.maxWorkflowSizeKb) {
|
||||
errors.push(
|
||||
`workflow_after size (${afterSizeKb}KB) exceeds maximum (${this.options.maxWorkflowSizeKb}KB)`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for meaningful change
|
||||
if (!this.hasMeaningfulChange(data.workflowBefore, data.workflowAfter)) {
|
||||
warnings.push('No meaningful change detected between before and after workflows');
|
||||
}
|
||||
|
||||
// Check intent quality
|
||||
if (!data.userIntent || data.userIntent.trim().length === 0) {
|
||||
warnings.push('User intent is empty');
|
||||
} else if (data.userIntent.trim().length < 5) {
|
||||
warnings.push('User intent is too short (less than 5 characters)');
|
||||
} else if (data.userIntent.length > 1000) {
|
||||
warnings.push('User intent is very long (over 1000 characters)');
|
||||
}
|
||||
|
||||
// Check operations
|
||||
if (!data.operations || data.operations.length === 0) {
|
||||
errors.push('No operations provided');
|
||||
}
|
||||
|
||||
// Check validation data consistency
|
||||
if (data.validationBefore && data.validationAfter) {
|
||||
if (typeof data.validationBefore.valid !== 'boolean') {
|
||||
warnings.push('Invalid validation_before structure');
|
||||
}
|
||||
if (typeof data.validationAfter.valid !== 'boolean') {
|
||||
warnings.push('Invalid validation_after structure');
|
||||
}
|
||||
}
|
||||
|
||||
// Check duration sanity
|
||||
if (data.durationMs !== undefined) {
|
||||
if (data.durationMs < 0) {
|
||||
errors.push('Duration cannot be negative');
|
||||
}
|
||||
if (data.durationMs > 300000) {
|
||||
// 5 minutes
|
||||
warnings.push('Duration is very long (over 5 minutes)');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if workflow has valid structure
|
||||
*/
|
||||
private isValidWorkflow(workflow: any): boolean {
|
||||
if (!workflow || typeof workflow !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have nodes array
|
||||
if (!Array.isArray(workflow.nodes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have connections object
|
||||
if (!workflow.connections || typeof workflow.connections !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow size in KB
|
||||
*/
|
||||
private getWorkflowSizeKb(workflow: any): number {
|
||||
try {
|
||||
const json = JSON.stringify(workflow);
|
||||
return json.length / 1024;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's meaningful change between workflows
|
||||
*/
|
||||
private hasMeaningfulChange(workflowBefore: any, workflowAfter: any): boolean {
|
||||
try {
|
||||
// Compare hashes
|
||||
const hashBefore = this.hashWorkflow(workflowBefore);
|
||||
const hashAfter = this.hashWorkflow(workflowAfter);
|
||||
|
||||
return hashBefore !== hashAfter;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash workflow for comparison
|
||||
*/
|
||||
hashWorkflow(workflow: any): string {
|
||||
try {
|
||||
const json = JSON.stringify(workflow);
|
||||
return createHash('sha256').update(json).digest('hex').substring(0, 16);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mutation should be excluded from tracking
|
||||
*/
|
||||
shouldExclude(data: WorkflowMutationData): boolean {
|
||||
// Exclude if not successful and no error message
|
||||
if (!data.mutationSuccess && !data.mutationError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exclude if workflows are identical
|
||||
if (!this.hasMeaningfulChange(data.workflowBefore, data.workflowAfter)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exclude if workflow size exceeds limits
|
||||
const beforeSizeKb = this.getWorkflowSizeKb(data.workflowBefore);
|
||||
const afterSizeKb = this.getWorkflowSizeKb(data.workflowAfter);
|
||||
|
||||
if (
|
||||
beforeSizeKb > this.options.maxWorkflowSizeKb ||
|
||||
afterSizeKb > this.options.maxWorkflowSizeKb
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for duplicate mutation (same hash + operations)
|
||||
*/
|
||||
isDuplicate(
|
||||
workflowBefore: any,
|
||||
workflowAfter: any,
|
||||
operations: any[],
|
||||
recentMutations: Array<{ hashBefore: string; hashAfter: string; operations: any[] }>
|
||||
): boolean {
|
||||
const hashBefore = this.hashWorkflow(workflowBefore);
|
||||
const hashAfter = this.hashWorkflow(workflowAfter);
|
||||
const operationsHash = this.hashOperations(operations);
|
||||
|
||||
return recentMutations.some(
|
||||
(m) =>
|
||||
m.hashBefore === hashBefore &&
|
||||
m.hashAfter === hashAfter &&
|
||||
this.hashOperations(m.operations) === operationsHash
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash operations for deduplication
|
||||
*/
|
||||
private hashOperations(operations: any[]): string {
|
||||
try {
|
||||
const json = JSON.stringify(operations);
|
||||
return createHash('sha256').update(json).digest('hex').substring(0, 16);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance for easy access
|
||||
*/
|
||||
export const mutationValidator = new MutationValidator();
|
||||
@@ -148,6 +148,50 @@ export class TelemetryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track workflow mutation from partial updates
|
||||
*/
|
||||
async trackWorkflowMutation(data: any): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
logger.debug('Telemetry disabled, skipping mutation tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
this.performanceMonitor.startOperation('trackWorkflowMutation');
|
||||
try {
|
||||
const { mutationTracker } = await import('./mutation-tracker.js');
|
||||
const userId = this.configManager.getUserId();
|
||||
|
||||
const mutationRecord = await mutationTracker.processMutation(data, userId);
|
||||
|
||||
if (mutationRecord) {
|
||||
// Queue for batch processing
|
||||
this.eventTracker.enqueueMutation(mutationRecord);
|
||||
|
||||
// Auto-flush if queue reaches threshold
|
||||
// Lower threshold (2) for mutations since they're less frequent than regular events
|
||||
const queueSize = this.eventTracker.getMutationQueueSize();
|
||||
if (queueSize >= 2) {
|
||||
await this.flushMutations();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const telemetryError = error instanceof TelemetryError
|
||||
? error
|
||||
: new TelemetryError(
|
||||
TelemetryErrorType.UNKNOWN_ERROR,
|
||||
'Failed to track workflow mutation',
|
||||
{ error: String(error) }
|
||||
);
|
||||
this.errorAggregator.record(telemetryError);
|
||||
logger.debug('Error tracking workflow mutation:', error);
|
||||
} finally {
|
||||
this.performanceMonitor.endOperation('trackWorkflowMutation');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Track an error event
|
||||
@@ -221,14 +265,16 @@ export class TelemetryManager {
|
||||
// Get queued data from event tracker
|
||||
const events = this.eventTracker.getEventQueue();
|
||||
const workflows = this.eventTracker.getWorkflowQueue();
|
||||
const mutations = this.eventTracker.getMutationQueue();
|
||||
|
||||
// Clear queues immediately to prevent duplicate processing
|
||||
this.eventTracker.clearEventQueue();
|
||||
this.eventTracker.clearWorkflowQueue();
|
||||
this.eventTracker.clearMutationQueue();
|
||||
|
||||
try {
|
||||
// Use batch processor to flush
|
||||
await this.batchProcessor.flush(events, workflows);
|
||||
await this.batchProcessor.flush(events, workflows, mutations);
|
||||
} catch (error) {
|
||||
const telemetryError = error instanceof TelemetryError
|
||||
? error
|
||||
@@ -248,6 +294,21 @@ export class TelemetryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush queued mutations only
|
||||
*/
|
||||
async flushMutations(): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
if (!this.isEnabled() || !this.supabase) return;
|
||||
|
||||
const mutations = this.eventTracker.getMutationQueue();
|
||||
this.eventTracker.clearMutationQueue();
|
||||
|
||||
if (mutations.length > 0) {
|
||||
await this.batchProcessor.flush([], [], mutations);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if telemetry is enabled
|
||||
|
||||
@@ -131,4 +131,9 @@ export interface TelemetryErrorContext {
|
||||
context?: Record<string, any>;
|
||||
timestamp: number;
|
||||
retryable: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export workflow mutation types
|
||||
*/
|
||||
export type { WorkflowMutationRecord, WorkflowMutationData } from './mutation-types.js';
|
||||
@@ -27,29 +27,32 @@ interface SanitizedWorkflow {
|
||||
workflowHash: string;
|
||||
}
|
||||
|
||||
interface PatternDefinition {
|
||||
pattern: RegExp;
|
||||
placeholder: string;
|
||||
preservePrefix?: boolean; // For patterns like "Bearer [REDACTED]"
|
||||
}
|
||||
|
||||
export class WorkflowSanitizer {
|
||||
private static readonly SENSITIVE_PATTERNS = [
|
||||
private static readonly SENSITIVE_PATTERNS: PatternDefinition[] = [
|
||||
// Webhook URLs (replace with placeholder but keep structure) - MUST BE FIRST
|
||||
/https?:\/\/[^\s/]+\/webhook\/[^\s]+/g,
|
||||
/https?:\/\/[^\s/]+\/hook\/[^\s]+/g,
|
||||
{ pattern: /https?:\/\/[^\s/]+\/webhook\/[^\s]+/g, placeholder: '[REDACTED_WEBHOOK]' },
|
||||
{ pattern: /https?:\/\/[^\s/]+\/hook\/[^\s]+/g, placeholder: '[REDACTED_WEBHOOK]' },
|
||||
|
||||
// API keys and tokens
|
||||
/sk-[a-zA-Z0-9]{16,}/g, // OpenAI keys
|
||||
/Bearer\s+[^\s]+/gi, // Bearer tokens
|
||||
/[a-zA-Z0-9_-]{20,}/g, // Long alphanumeric strings (API keys) - reduced threshold
|
||||
/token['":\s]+[^,}]+/gi, // Token fields
|
||||
/apikey['":\s]+[^,}]+/gi, // API key fields
|
||||
/api_key['":\s]+[^,}]+/gi,
|
||||
/secret['":\s]+[^,}]+/gi,
|
||||
/password['":\s]+[^,}]+/gi,
|
||||
/credential['":\s]+[^,}]+/gi,
|
||||
// URLs with authentication - MUST BE BEFORE BEARER TOKENS
|
||||
{ pattern: /https?:\/\/[^:]+:[^@]+@[^\s/]+/g, placeholder: '[REDACTED_URL_WITH_AUTH]' },
|
||||
{ pattern: /wss?:\/\/[^:]+:[^@]+@[^\s/]+/g, placeholder: '[REDACTED_URL_WITH_AUTH]' },
|
||||
{ pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^:]+:[^@]+@[^\s]+/g, placeholder: '[REDACTED_URL_WITH_AUTH]' }, // Database protocols - includes port and path
|
||||
|
||||
// URLs with authentication
|
||||
/https?:\/\/[^:]+:[^@]+@[^\s/]+/g, // URLs with auth
|
||||
/wss?:\/\/[^:]+:[^@]+@[^\s/]+/g,
|
||||
// API keys and tokens - ORDER MATTERS!
|
||||
// More specific patterns first, then general patterns
|
||||
{ pattern: /sk-[a-zA-Z0-9]{16,}/g, placeholder: '[REDACTED_APIKEY]' }, // OpenAI keys
|
||||
{ pattern: /Bearer\s+[^\s]+/gi, placeholder: 'Bearer [REDACTED]', preservePrefix: true }, // Bearer tokens
|
||||
{ pattern: /\b[a-zA-Z0-9_-]{32,}\b/g, placeholder: '[REDACTED_TOKEN]' }, // Long tokens (32+ chars)
|
||||
{ pattern: /\b[a-zA-Z0-9_-]{20,31}\b/g, placeholder: '[REDACTED]' }, // Short tokens (20-31 chars)
|
||||
|
||||
// Email addresses (optional - uncomment if needed)
|
||||
// /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
||||
// { pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, placeholder: '[REDACTED_EMAIL]' },
|
||||
];
|
||||
|
||||
private static readonly SENSITIVE_FIELDS = [
|
||||
@@ -178,19 +181,34 @@ export class WorkflowSanitizer {
|
||||
const sanitized: any = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Check if key is sensitive
|
||||
if (this.isSensitiveField(key)) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
continue;
|
||||
}
|
||||
// Check if field name is sensitive
|
||||
const isSensitive = this.isSensitiveField(key);
|
||||
const isUrlField = key.toLowerCase().includes('url') ||
|
||||
key.toLowerCase().includes('endpoint') ||
|
||||
key.toLowerCase().includes('webhook');
|
||||
|
||||
// Recursively sanitize nested objects
|
||||
// Recursively sanitize nested objects (unless it's a sensitive non-URL field)
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
sanitized[key] = this.sanitizeObject(value);
|
||||
if (isSensitive && !isUrlField) {
|
||||
// For sensitive object fields (like 'authentication'), redact completely
|
||||
sanitized[key] = '[REDACTED]';
|
||||
} else {
|
||||
sanitized[key] = this.sanitizeObject(value);
|
||||
}
|
||||
}
|
||||
// Sanitize string values
|
||||
else if (typeof value === 'string') {
|
||||
sanitized[key] = this.sanitizeString(value, key);
|
||||
// For sensitive fields (except URL fields), use generic redaction
|
||||
if (isSensitive && !isUrlField) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
} else {
|
||||
// For URL fields or non-sensitive fields, use pattern-specific sanitization
|
||||
sanitized[key] = this.sanitizeString(value, key);
|
||||
}
|
||||
}
|
||||
// For non-string sensitive fields, redact completely
|
||||
else if (isSensitive) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
}
|
||||
// Keep other types as-is
|
||||
else {
|
||||
@@ -212,13 +230,42 @@ export class WorkflowSanitizer {
|
||||
|
||||
let sanitized = value;
|
||||
|
||||
// Apply all sensitive patterns
|
||||
for (const pattern of this.SENSITIVE_PATTERNS) {
|
||||
// Apply all sensitive patterns with their specific placeholders
|
||||
for (const patternDef of this.SENSITIVE_PATTERNS) {
|
||||
// Skip webhook patterns - already handled above
|
||||
if (pattern.toString().includes('webhook')) {
|
||||
if (patternDef.placeholder.includes('WEBHOOK')) {
|
||||
continue;
|
||||
}
|
||||
sanitized = sanitized.replace(pattern, '[REDACTED]');
|
||||
|
||||
// Skip if already sanitized with a placeholder to prevent double-redaction
|
||||
if (sanitized.includes('[REDACTED')) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Special handling for URL with auth - preserve path after credentials
|
||||
if (patternDef.placeholder === '[REDACTED_URL_WITH_AUTH]') {
|
||||
const matches = value.match(patternDef.pattern);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
// Extract path after the authenticated URL
|
||||
const fullUrlMatch = value.indexOf(match);
|
||||
if (fullUrlMatch !== -1) {
|
||||
const afterUrl = value.substring(fullUrlMatch + match.length);
|
||||
// If there's a path after the URL, preserve it
|
||||
if (afterUrl && afterUrl.startsWith('/')) {
|
||||
const pathPart = afterUrl.split(/[\s?&#]/)[0]; // Get path until query/fragment
|
||||
sanitized = sanitized.replace(match + pathPart, patternDef.placeholder + pathPart);
|
||||
} else {
|
||||
sanitized = sanitized.replace(match, patternDef.placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply pattern with its specific placeholder
|
||||
sanitized = sanitized.replace(patternDef.pattern, patternDef.placeholder);
|
||||
}
|
||||
|
||||
// Additional sanitization for specific field types
|
||||
@@ -226,9 +273,13 @@ export class WorkflowSanitizer {
|
||||
fieldName.toLowerCase().includes('endpoint')) {
|
||||
// Keep URL structure but remove domain details
|
||||
if (sanitized.startsWith('http://') || sanitized.startsWith('https://')) {
|
||||
// If value has been redacted, leave it as is
|
||||
// If value has been redacted with URL_WITH_AUTH, preserve it
|
||||
if (sanitized.includes('[REDACTED_URL_WITH_AUTH]')) {
|
||||
return sanitized; // Already properly sanitized with path preserved
|
||||
}
|
||||
// If value has other redactions, leave it as is
|
||||
if (sanitized.includes('[REDACTED]')) {
|
||||
return '[REDACTED]';
|
||||
return sanitized;
|
||||
}
|
||||
const urlParts = sanitized.split('/');
|
||||
if (urlParts.length > 2) {
|
||||
@@ -296,4 +347,37 @@ export class WorkflowSanitizer {
|
||||
const sanitized = this.sanitizeWorkflow(workflow);
|
||||
return sanitized.workflowHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize workflow and return raw workflow object (without metrics)
|
||||
* For use in telemetry where we need plain workflow structure
|
||||
*/
|
||||
static sanitizeWorkflowRaw(workflow: any): any {
|
||||
// Create a deep copy to avoid modifying original
|
||||
const sanitized = JSON.parse(JSON.stringify(workflow));
|
||||
|
||||
// Sanitize nodes
|
||||
if (sanitized.nodes && Array.isArray(sanitized.nodes)) {
|
||||
sanitized.nodes = sanitized.nodes.map((node: WorkflowNode) =>
|
||||
this.sanitizeNode(node)
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize connections (keep structure only)
|
||||
if (sanitized.connections) {
|
||||
sanitized.connections = this.sanitizeConnections(sanitized.connections);
|
||||
}
|
||||
|
||||
// Remove other potentially sensitive data
|
||||
delete sanitized.settings?.errorWorkflow;
|
||||
delete sanitized.staticData;
|
||||
delete sanitized.pinData;
|
||||
delete sanitized.credentials;
|
||||
delete sanitized.sharedWorkflows;
|
||||
delete sanitized.ownedBy;
|
||||
delete sanitized.createdBy;
|
||||
delete sanitized.updatedBy;
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,37 @@ export interface TemplateDetail {
|
||||
export class TemplateFetcher {
|
||||
private readonly baseUrl = 'https://api.n8n.io/api/templates';
|
||||
private readonly pageSize = 250; // Maximum allowed by API
|
||||
|
||||
private readonly maxRetries = 3;
|
||||
private readonly retryDelay = 1000; // 1 second base delay
|
||||
|
||||
/**
|
||||
* Retry helper for API calls
|
||||
*/
|
||||
private async retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
context: string,
|
||||
maxRetries: number = this.maxRetries
|
||||
): Promise<T | null> {
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
const delay = this.retryDelay * attempt; // Exponential backoff
|
||||
logger.warn(`${context} - Attempt ${attempt}/${maxRetries} failed, retrying in ${delay}ms...`);
|
||||
await this.sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`${context} - All ${maxRetries} attempts failed, skipping`, lastError);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all templates and filter to last 12 months
|
||||
* This fetches ALL pages first, then applies date filter locally
|
||||
@@ -73,93 +103,105 @@ export class TemplateFetcher {
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
let totalWorkflows = 0;
|
||||
|
||||
|
||||
logger.info('Starting complete template fetch from n8n.io API');
|
||||
|
||||
|
||||
while (hasMore) {
|
||||
try {
|
||||
const response = await axios.get(`${this.baseUrl}/search`, {
|
||||
params: {
|
||||
page,
|
||||
rows: this.pageSize
|
||||
// Note: sort_by parameter doesn't work, templates come in popularity order
|
||||
}
|
||||
});
|
||||
|
||||
const { workflows } = response.data;
|
||||
totalWorkflows = response.data.totalWorkflows || totalWorkflows;
|
||||
|
||||
allTemplates.push(...workflows);
|
||||
|
||||
// Calculate total pages for better progress reporting
|
||||
const totalPages = Math.ceil(totalWorkflows / this.pageSize);
|
||||
|
||||
if (progressCallback) {
|
||||
// Enhanced progress with page information
|
||||
progressCallback(allTemplates.length, totalWorkflows);
|
||||
}
|
||||
|
||||
logger.debug(`Fetched page ${page}/${totalPages}: ${workflows.length} templates (total so far: ${allTemplates.length}/${totalWorkflows})`);
|
||||
|
||||
// Check if there are more pages
|
||||
if (workflows.length < this.pageSize) {
|
||||
hasMore = false;
|
||||
}
|
||||
|
||||
const result = await this.retryWithBackoff(
|
||||
async () => {
|
||||
const response = await axios.get(`${this.baseUrl}/search`, {
|
||||
params: {
|
||||
page,
|
||||
rows: this.pageSize
|
||||
// Note: sort_by parameter doesn't work, templates come in popularity order
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
`Fetching templates page ${page}`
|
||||
);
|
||||
|
||||
if (result === null) {
|
||||
// All retries failed for this page, skip it and continue
|
||||
logger.warn(`Skipping page ${page} after ${this.maxRetries} failed attempts`);
|
||||
page++;
|
||||
|
||||
// Rate limiting - be nice to the API (slightly faster with 250 rows/page)
|
||||
if (hasMore) {
|
||||
await this.sleep(300); // 300ms between requests (was 500ms with 100 rows)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching templates page ${page}:`, error);
|
||||
throw error;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { workflows } = result;
|
||||
totalWorkflows = result.totalWorkflows || totalWorkflows;
|
||||
|
||||
allTemplates.push(...workflows);
|
||||
|
||||
// Calculate total pages for better progress reporting
|
||||
const totalPages = Math.ceil(totalWorkflows / this.pageSize);
|
||||
|
||||
if (progressCallback) {
|
||||
// Enhanced progress with page information
|
||||
progressCallback(allTemplates.length, totalWorkflows);
|
||||
}
|
||||
|
||||
logger.debug(`Fetched page ${page}/${totalPages}: ${workflows.length} templates (total so far: ${allTemplates.length}/${totalWorkflows})`);
|
||||
|
||||
// Check if there are more pages
|
||||
if (workflows.length < this.pageSize) {
|
||||
hasMore = false;
|
||||
}
|
||||
|
||||
page++;
|
||||
|
||||
// Rate limiting - be nice to the API (slightly faster with 250 rows/page)
|
||||
if (hasMore) {
|
||||
await this.sleep(300); // 300ms between requests (was 500ms with 100 rows)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logger.info(`Fetched all ${allTemplates.length} templates from n8n.io`);
|
||||
return allTemplates;
|
||||
}
|
||||
|
||||
async fetchTemplateDetail(workflowId: number): Promise<TemplateDetail> {
|
||||
try {
|
||||
const response = await axios.get(`${this.baseUrl}/workflows/${workflowId}`);
|
||||
return response.data.workflow;
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching template detail for ${workflowId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
async fetchTemplateDetail(workflowId: number): Promise<TemplateDetail | null> {
|
||||
const result = await this.retryWithBackoff(
|
||||
async () => {
|
||||
const response = await axios.get(`${this.baseUrl}/workflows/${workflowId}`);
|
||||
return response.data.workflow;
|
||||
},
|
||||
`Fetching template detail for workflow ${workflowId}`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchAllTemplateDetails(
|
||||
workflows: TemplateWorkflow[],
|
||||
workflows: TemplateWorkflow[],
|
||||
progressCallback?: (current: number, total: number) => void
|
||||
): Promise<Map<number, TemplateDetail>> {
|
||||
const details = new Map<number, TemplateDetail>();
|
||||
|
||||
let skipped = 0;
|
||||
|
||||
logger.info(`Fetching details for ${workflows.length} templates`);
|
||||
|
||||
|
||||
for (let i = 0; i < workflows.length; i++) {
|
||||
const workflow = workflows[i];
|
||||
|
||||
try {
|
||||
const detail = await this.fetchTemplateDetail(workflow.id);
|
||||
|
||||
const detail = await this.fetchTemplateDetail(workflow.id);
|
||||
|
||||
if (detail !== null) {
|
||||
details.set(workflow.id, detail);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(i + 1, workflows.length);
|
||||
}
|
||||
|
||||
// Rate limiting (conservative to avoid API throttling)
|
||||
await this.sleep(150); // 150ms between requests
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch details for workflow ${workflow.id}:`, error);
|
||||
// Continue with other templates
|
||||
} else {
|
||||
skipped++;
|
||||
logger.warn(`Skipped workflow ${workflow.id} after ${this.maxRetries} failed attempts`);
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(i + 1, workflows.length);
|
||||
}
|
||||
|
||||
// Rate limiting (conservative to avoid API throttling)
|
||||
await this.sleep(150); // 150ms between requests
|
||||
}
|
||||
|
||||
logger.info(`Successfully fetched ${details.size} template details`);
|
||||
|
||||
logger.info(`Successfully fetched ${details.size} template details (${skipped} skipped)`);
|
||||
return details;
|
||||
}
|
||||
|
||||
|
||||
@@ -496,10 +496,17 @@ export class TemplateRepository {
|
||||
// Count node usage
|
||||
const nodeCount: Record<string, number> = {};
|
||||
topNodes.forEach(t => {
|
||||
const nodes = JSON.parse(t.nodes_used);
|
||||
nodes.forEach((n: string) => {
|
||||
nodeCount[n] = (nodeCount[n] || 0) + 1;
|
||||
});
|
||||
if (!t.nodes_used) return;
|
||||
try {
|
||||
const nodes = JSON.parse(t.nodes_used);
|
||||
if (Array.isArray(nodes)) {
|
||||
nodes.forEach((n: string) => {
|
||||
nodeCount[n] = (nodeCount[n] || 0) + 1;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse nodes_used for template stats:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get top 10 most used nodes
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Export n8n node type definitions and utilities
|
||||
export * from './node-types';
|
||||
export * from './type-structures';
|
||||
export * from './instance-context';
|
||||
export * from './session-state';
|
||||
|
||||
export interface MCPServerConfig {
|
||||
port: number;
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface WorkflowSettings {
|
||||
export interface Workflow {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string; // Returned by GET but must be excluded from PUT/PATCH (n8n API limitation, Issue #431)
|
||||
nodes: WorkflowNode[];
|
||||
connections: WorkflowConnection;
|
||||
active?: boolean; // Optional for creation as it's read-only
|
||||
@@ -66,6 +67,7 @@ export interface Workflow {
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
versionId?: string;
|
||||
versionCounter?: number; // Added: n8n 1.118.1+ returns this in GET responses
|
||||
meta?: {
|
||||
instanceId?: string;
|
||||
};
|
||||
@@ -152,6 +154,7 @@ export interface WorkflowExport {
|
||||
tags?: string[];
|
||||
pinData?: Record<string, unknown>;
|
||||
versionId?: string;
|
||||
versionCounter?: number; // Added: n8n 1.118.1+
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
92
src/types/session-state.ts
Normal file
92
src/types/session-state.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Session persistence types for multi-tenant deployments
|
||||
*
|
||||
* These types support exporting and restoring MCP session state across
|
||||
* container restarts, enabling seamless session persistence in production.
|
||||
*/
|
||||
|
||||
import { InstanceContext } from './instance-context.js';
|
||||
|
||||
/**
|
||||
* Serializable session state for persistence across restarts
|
||||
*
|
||||
* This interface represents the minimal state needed to restore an MCP session
|
||||
* after a container restart. Only the session metadata and instance context are
|
||||
* persisted - transport and server objects are recreated on the first request.
|
||||
*
|
||||
* @example
|
||||
* // Export sessions before shutdown
|
||||
* const sessions = server.exportSessionState();
|
||||
* await saveToEncryptedStorage(sessions);
|
||||
*
|
||||
* @example
|
||||
* // Restore sessions on startup
|
||||
* const sessions = await loadFromEncryptedStorage();
|
||||
* const count = server.restoreSessionState(sessions);
|
||||
* console.log(`Restored ${count} sessions`);
|
||||
*/
|
||||
export interface SessionState {
|
||||
/**
|
||||
* Unique session identifier
|
||||
* Format: UUID v4 or custom format from MCP proxy
|
||||
*/
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* Session timing metadata for expiration tracking
|
||||
*/
|
||||
metadata: {
|
||||
/**
|
||||
* When the session was created (ISO 8601 timestamp)
|
||||
* Used to track total session age
|
||||
*/
|
||||
createdAt: string;
|
||||
|
||||
/**
|
||||
* When the session was last accessed (ISO 8601 timestamp)
|
||||
* Used to determine if session has expired based on timeout
|
||||
*/
|
||||
lastAccess: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* n8n instance context (credentials and configuration)
|
||||
*
|
||||
* Contains the n8n API credentials and instance-specific settings.
|
||||
* This is the critical data needed to reconnect to the correct n8n instance.
|
||||
*
|
||||
* Note: API keys are stored in plaintext. The downstream application
|
||||
* MUST encrypt this data before persisting to disk.
|
||||
*/
|
||||
context: {
|
||||
/**
|
||||
* n8n instance API URL
|
||||
* Example: "https://n8n.example.com"
|
||||
*/
|
||||
n8nApiUrl: string;
|
||||
|
||||
/**
|
||||
* n8n instance API key (plaintext - encrypt before storage!)
|
||||
* Example: "n8n_api_1234567890abcdef"
|
||||
*/
|
||||
n8nApiKey: string;
|
||||
|
||||
/**
|
||||
* Instance identifier (optional)
|
||||
* Custom identifier for tracking which n8n instance this session belongs to
|
||||
*/
|
||||
instanceId?: string;
|
||||
|
||||
/**
|
||||
* Session-specific ID (optional)
|
||||
* May differ from top-level sessionId in some proxy configurations
|
||||
*/
|
||||
sessionId?: string;
|
||||
|
||||
/**
|
||||
* Additional metadata (optional)
|
||||
* Extensible field for custom application data
|
||||
*/
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
301
src/types/type-structures.ts
Normal file
301
src/types/type-structures.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Type Structure Definitions
|
||||
*
|
||||
* Defines the structure and validation rules for n8n node property types.
|
||||
* These structures help validate node configurations and provide better
|
||||
* AI assistance by clearly defining what each property type expects.
|
||||
*
|
||||
* @module types/type-structures
|
||||
* @since 2.23.0
|
||||
*/
|
||||
|
||||
import type { NodePropertyTypes } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Structure definition for a node property type
|
||||
*
|
||||
* Describes the expected data structure, JavaScript type,
|
||||
* example values, and validation rules for each property type.
|
||||
*
|
||||
* @interface TypeStructure
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const stringStructure: TypeStructure = {
|
||||
* type: 'primitive',
|
||||
* jsType: 'string',
|
||||
* description: 'A text value',
|
||||
* example: 'Hello World',
|
||||
* validation: {
|
||||
* allowEmpty: true,
|
||||
* allowExpressions: true
|
||||
* }
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export interface TypeStructure {
|
||||
/**
|
||||
* Category of the type
|
||||
* - primitive: Basic JavaScript types (string, number, boolean)
|
||||
* - object: Complex object structures
|
||||
* - array: Array types
|
||||
* - collection: n8n collection types (nested properties)
|
||||
* - special: Special n8n types with custom behavior
|
||||
*/
|
||||
type: 'primitive' | 'object' | 'array' | 'collection' | 'special';
|
||||
|
||||
/**
|
||||
* Underlying JavaScript type
|
||||
*/
|
||||
jsType: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any';
|
||||
|
||||
/**
|
||||
* Human-readable description of the type
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Detailed structure definition for complex types
|
||||
* Describes the expected shape of the data
|
||||
*/
|
||||
structure?: {
|
||||
/**
|
||||
* For objects: map of property names to their types
|
||||
*/
|
||||
properties?: Record<string, TypePropertyDefinition>;
|
||||
|
||||
/**
|
||||
* For arrays: type of array items
|
||||
*/
|
||||
items?: TypePropertyDefinition;
|
||||
|
||||
/**
|
||||
* Whether the structure is flexible (allows additional properties)
|
||||
*/
|
||||
flexible?: boolean;
|
||||
|
||||
/**
|
||||
* Required properties (for objects)
|
||||
*/
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Example value demonstrating correct usage
|
||||
*/
|
||||
example: any;
|
||||
|
||||
/**
|
||||
* Additional example values for complex types
|
||||
*/
|
||||
examples?: any[];
|
||||
|
||||
/**
|
||||
* Validation rules specific to this type
|
||||
*/
|
||||
validation?: {
|
||||
/**
|
||||
* Whether empty values are allowed
|
||||
*/
|
||||
allowEmpty?: boolean;
|
||||
|
||||
/**
|
||||
* Whether n8n expressions ({{ ... }}) are allowed
|
||||
*/
|
||||
allowExpressions?: boolean;
|
||||
|
||||
/**
|
||||
* Minimum value (for numbers)
|
||||
*/
|
||||
min?: number;
|
||||
|
||||
/**
|
||||
* Maximum value (for numbers)
|
||||
*/
|
||||
max?: number;
|
||||
|
||||
/**
|
||||
* Pattern to match (for strings)
|
||||
*/
|
||||
pattern?: string;
|
||||
|
||||
/**
|
||||
* Custom validation function name
|
||||
*/
|
||||
customValidator?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Version when this type was introduced
|
||||
*/
|
||||
introducedIn?: string;
|
||||
|
||||
/**
|
||||
* Version when this type was deprecated (if applicable)
|
||||
*/
|
||||
deprecatedIn?: string;
|
||||
|
||||
/**
|
||||
* Type that replaces this one (if deprecated)
|
||||
*/
|
||||
replacedBy?: NodePropertyTypes;
|
||||
|
||||
/**
|
||||
* Additional notes or warnings
|
||||
*/
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Property definition within a structure
|
||||
*/
|
||||
export interface TypePropertyDefinition {
|
||||
/**
|
||||
* Type of this property
|
||||
*/
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any';
|
||||
|
||||
/**
|
||||
* Description of this property
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* Whether this property is required
|
||||
*/
|
||||
required?: boolean;
|
||||
|
||||
/**
|
||||
* Nested properties (for object types)
|
||||
*/
|
||||
properties?: Record<string, TypePropertyDefinition>;
|
||||
|
||||
/**
|
||||
* Type of array items (for array types)
|
||||
*/
|
||||
items?: TypePropertyDefinition;
|
||||
|
||||
/**
|
||||
* Example value
|
||||
*/
|
||||
example?: any;
|
||||
|
||||
/**
|
||||
* Allowed values (enum)
|
||||
*/
|
||||
enum?: Array<string | number | boolean>;
|
||||
|
||||
/**
|
||||
* Whether this structure allows additional properties beyond those defined
|
||||
*/
|
||||
flexible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complex property types that have nested structures
|
||||
*
|
||||
* These types require special handling and validation
|
||||
* beyond simple type checking.
|
||||
*/
|
||||
export type ComplexPropertyType =
|
||||
| 'collection'
|
||||
| 'fixedCollection'
|
||||
| 'resourceLocator'
|
||||
| 'resourceMapper'
|
||||
| 'filter'
|
||||
| 'assignmentCollection';
|
||||
|
||||
/**
|
||||
* Primitive property types (simple values)
|
||||
*
|
||||
* These types map directly to JavaScript primitives
|
||||
* and don't require complex validation.
|
||||
*/
|
||||
export type PrimitivePropertyType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'dateTime'
|
||||
| 'color'
|
||||
| 'json';
|
||||
|
||||
/**
|
||||
* Type guard to check if a property type is complex
|
||||
*
|
||||
* Complex types have nested structures and require
|
||||
* special validation logic.
|
||||
*
|
||||
* @param type - The property type to check
|
||||
* @returns True if the type is complex
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (isComplexType('collection')) {
|
||||
* // Handle complex type
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isComplexType(type: NodePropertyTypes): type is ComplexPropertyType {
|
||||
return (
|
||||
type === 'collection' ||
|
||||
type === 'fixedCollection' ||
|
||||
type === 'resourceLocator' ||
|
||||
type === 'resourceMapper' ||
|
||||
type === 'filter' ||
|
||||
type === 'assignmentCollection'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a property type is primitive
|
||||
*
|
||||
* Primitive types map to simple JavaScript values
|
||||
* and only need basic type validation.
|
||||
*
|
||||
* @param type - The property type to check
|
||||
* @returns True if the type is primitive
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (isPrimitiveType('string')) {
|
||||
* // Handle as primitive
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isPrimitiveType(type: NodePropertyTypes): type is PrimitivePropertyType {
|
||||
return (
|
||||
type === 'string' ||
|
||||
type === 'number' ||
|
||||
type === 'boolean' ||
|
||||
type === 'dateTime' ||
|
||||
type === 'color' ||
|
||||
type === 'json'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a valid TypeStructure
|
||||
*
|
||||
* @param value - The value to check
|
||||
* @returns True if the value conforms to TypeStructure interface
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const maybeStructure = getStructureFromSomewhere();
|
||||
* if (isTypeStructure(maybeStructure)) {
|
||||
* console.log(maybeStructure.example);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isTypeStructure(value: any): value is TypeStructure {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'type' in value &&
|
||||
'jsType' in value &&
|
||||
'description' in value &&
|
||||
'example' in value &&
|
||||
['primitive', 'object', 'array', 'collection', 'special'].includes(value.type) &&
|
||||
['string', 'number', 'boolean', 'object', 'array', 'any'].includes(value.jsType)
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,16 @@ export interface RemoveTagOperation extends DiffOperation {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface ActivateWorkflowOperation extends DiffOperation {
|
||||
type: 'activateWorkflow';
|
||||
// No additional properties needed - just activates the workflow
|
||||
}
|
||||
|
||||
export interface DeactivateWorkflowOperation extends DiffOperation {
|
||||
type: 'deactivateWorkflow';
|
||||
// No additional properties needed - just deactivates the workflow
|
||||
}
|
||||
|
||||
// Connection Cleanup Operations
|
||||
export interface CleanStaleConnectionsOperation extends DiffOperation {
|
||||
type: 'cleanStaleConnections';
|
||||
@@ -148,6 +158,8 @@ export type WorkflowDiffOperation =
|
||||
| UpdateNameOperation
|
||||
| AddTagOperation
|
||||
| RemoveTagOperation
|
||||
| ActivateWorkflowOperation
|
||||
| DeactivateWorkflowOperation
|
||||
| CleanStaleConnectionsOperation
|
||||
| ReplaceConnectionsOperation;
|
||||
|
||||
@@ -170,11 +182,14 @@ export interface WorkflowDiffResult {
|
||||
success: boolean;
|
||||
workflow?: any; // Updated workflow if successful
|
||||
errors?: WorkflowDiffValidationError[];
|
||||
warnings?: WorkflowDiffValidationError[]; // Non-blocking warnings (e.g., parameter suggestions)
|
||||
operationsApplied?: number;
|
||||
message?: string;
|
||||
applied?: number[]; // Indices of successfully applied operations (when continueOnError is true)
|
||||
failed?: number[]; // Indices of failed operations (when continueOnError is true)
|
||||
staleConnectionsRemoved?: Array<{ from: string; to: string }>; // For cleanStaleConnections operation
|
||||
shouldActivate?: boolean; // Flag to activate workflow after update (for activateWorkflow operation)
|
||||
shouldDeactivate?: boolean; // Flag to deactivate workflow after update (for deactivateWorkflow operation)
|
||||
}
|
||||
|
||||
// Helper type for node reference (supports both ID and name)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from './logger';
|
||||
import { execSync } from 'child_process';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
// Enhanced documentation structure with rich content
|
||||
export interface EnhancedNodeDocumentation {
|
||||
@@ -61,36 +61,136 @@ export interface DocumentationMetadata {
|
||||
|
||||
export class EnhancedDocumentationFetcher {
|
||||
private docsPath: string;
|
||||
private docsRepoUrl = 'https://github.com/n8n-io/n8n-docs.git';
|
||||
private readonly docsRepoUrl = 'https://github.com/n8n-io/n8n-docs.git';
|
||||
private cloned = false;
|
||||
|
||||
constructor(docsPath?: string) {
|
||||
this.docsPath = docsPath || path.join(__dirname, '../../temp', 'n8n-docs');
|
||||
// SECURITY: Validate and sanitize docsPath to prevent command injection
|
||||
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01 Part 2)
|
||||
const defaultPath = path.join(__dirname, '../../temp', 'n8n-docs');
|
||||
|
||||
if (!docsPath) {
|
||||
this.docsPath = defaultPath;
|
||||
} else {
|
||||
// SECURITY: Block directory traversal and malicious paths
|
||||
const sanitized = this.sanitizePath(docsPath);
|
||||
|
||||
if (!sanitized) {
|
||||
logger.error('Invalid docsPath rejected in constructor', { docsPath });
|
||||
throw new Error('Invalid docsPath: path contains disallowed characters or patterns');
|
||||
}
|
||||
|
||||
// SECURITY: Verify path is absolute and within allowed boundaries
|
||||
const absolutePath = path.resolve(sanitized);
|
||||
|
||||
// Block paths that could escape to sensitive directories
|
||||
if (absolutePath.startsWith('/etc') ||
|
||||
absolutePath.startsWith('/sys') ||
|
||||
absolutePath.startsWith('/proc') ||
|
||||
absolutePath.startsWith('/var/log')) {
|
||||
logger.error('docsPath points to system directory - blocked', { docsPath, absolutePath });
|
||||
throw new Error('Invalid docsPath: cannot use system directories');
|
||||
}
|
||||
|
||||
this.docsPath = absolutePath;
|
||||
logger.info('docsPath validated and set', { docsPath: this.docsPath });
|
||||
}
|
||||
|
||||
// SECURITY: Validate repository URL is HTTPS
|
||||
if (!this.docsRepoUrl.startsWith('https://')) {
|
||||
logger.error('docsRepoUrl must use HTTPS protocol', { url: this.docsRepoUrl });
|
||||
throw new Error('Invalid repository URL: must use HTTPS protocol');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize path input to prevent command injection and directory traversal
|
||||
* SECURITY: Part of fix for command injection vulnerability
|
||||
*/
|
||||
private sanitizePath(inputPath: string): string | null {
|
||||
// SECURITY: Reject paths containing any shell metacharacters or control characters
|
||||
// This prevents command injection even before attempting to sanitize
|
||||
const dangerousChars = /[;&|`$(){}[\]<>'"\\#\n\r\t]/;
|
||||
if (dangerousChars.test(inputPath)) {
|
||||
logger.warn('Path contains shell metacharacters - rejected', { path: inputPath });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block directory traversal attempts
|
||||
if (inputPath.includes('..') || inputPath.startsWith('.')) {
|
||||
logger.warn('Path traversal attempt blocked', { path: inputPath });
|
||||
return null;
|
||||
}
|
||||
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone or update the n8n-docs repository
|
||||
* SECURITY: Uses spawnSync with argument arrays to prevent command injection
|
||||
* See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01 Part 2)
|
||||
*/
|
||||
async ensureDocsRepository(): Promise<void> {
|
||||
try {
|
||||
const exists = await fs.access(this.docsPath).then(() => true).catch(() => false);
|
||||
|
||||
|
||||
if (!exists) {
|
||||
logger.info('Cloning n8n-docs repository...');
|
||||
await fs.mkdir(path.dirname(this.docsPath), { recursive: true });
|
||||
execSync(`git clone --depth 1 ${this.docsRepoUrl} ${this.docsPath}`, {
|
||||
stdio: 'pipe'
|
||||
logger.info('Cloning n8n-docs repository...', {
|
||||
url: this.docsRepoUrl,
|
||||
path: this.docsPath
|
||||
});
|
||||
await fs.mkdir(path.dirname(this.docsPath), { recursive: true });
|
||||
|
||||
// SECURITY: Use spawnSync with argument array instead of string interpolation
|
||||
// This prevents command injection even if docsPath or docsRepoUrl are compromised
|
||||
const cloneResult = spawnSync('git', [
|
||||
'clone',
|
||||
'--depth', '1',
|
||||
this.docsRepoUrl,
|
||||
this.docsPath
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
if (cloneResult.status !== 0) {
|
||||
const error = cloneResult.stderr || cloneResult.error?.message || 'Unknown error';
|
||||
logger.error('Git clone failed', {
|
||||
status: cloneResult.status,
|
||||
stderr: error,
|
||||
url: this.docsRepoUrl,
|
||||
path: this.docsPath
|
||||
});
|
||||
throw new Error(`Git clone failed: ${error}`);
|
||||
}
|
||||
|
||||
logger.info('n8n-docs repository cloned successfully');
|
||||
} else {
|
||||
logger.info('Updating n8n-docs repository...');
|
||||
execSync('git pull --ff-only', {
|
||||
logger.info('Updating n8n-docs repository...', { path: this.docsPath });
|
||||
|
||||
// SECURITY: Use spawnSync with argument array and cwd option
|
||||
const pullResult = spawnSync('git', [
|
||||
'pull',
|
||||
'--ff-only'
|
||||
], {
|
||||
cwd: this.docsPath,
|
||||
stdio: 'pipe'
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
if (pullResult.status !== 0) {
|
||||
const error = pullResult.stderr || pullResult.error?.message || 'Unknown error';
|
||||
logger.error('Git pull failed', {
|
||||
status: pullResult.status,
|
||||
stderr: error,
|
||||
cwd: this.docsPath
|
||||
});
|
||||
throw new Error(`Git pull failed: ${error}`);
|
||||
}
|
||||
|
||||
logger.info('n8n-docs repository updated');
|
||||
}
|
||||
|
||||
|
||||
this.cloned = true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to clone/update n8n-docs repository:', error);
|
||||
|
||||
109
src/utils/expression-utils.ts
Normal file
109
src/utils/expression-utils.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Utility functions for detecting and handling n8n expressions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detects if a value is an n8n expression
|
||||
*
|
||||
* n8n expressions can be:
|
||||
* - Pure expression: `={{ $json.value }}`
|
||||
* - Mixed content: `=https://api.com/{{ $json.id }}/data`
|
||||
* - Prefix-only: `=$json.value`
|
||||
*
|
||||
* @param value - The value to check
|
||||
* @returns true if the value is an expression (starts with =)
|
||||
*/
|
||||
export function isExpression(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.startsWith('=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a string contains n8n expression syntax {{ }}
|
||||
*
|
||||
* This checks for expression markers within the string,
|
||||
* regardless of whether it has the = prefix.
|
||||
*
|
||||
* @param value - The value to check
|
||||
* @returns true if the value contains {{ }} markers
|
||||
*/
|
||||
export function containsExpression(value: unknown): boolean {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
// Use single regex for better performance than two includes()
|
||||
return /\{\{.*\}\}/s.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a value should skip literal validation
|
||||
*
|
||||
* This is the main utility to use before validating values like URLs, JSON, etc.
|
||||
* It returns true if:
|
||||
* - The value is an expression (starts with =)
|
||||
* - OR the value contains expression markers {{ }}
|
||||
*
|
||||
* @param value - The value to check
|
||||
* @returns true if validation should be skipped
|
||||
*/
|
||||
export function shouldSkipLiteralValidation(value: unknown): boolean {
|
||||
return isExpression(value) || containsExpression(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the expression content from a value
|
||||
*
|
||||
* If value is `={{ $json.value }}`, returns `$json.value`
|
||||
* If value is `=$json.value`, returns `$json.value`
|
||||
* If value is not an expression, returns the original value
|
||||
*
|
||||
* @param value - The value to extract from
|
||||
* @returns The expression content or original value
|
||||
*/
|
||||
export function extractExpressionContent(value: string): string {
|
||||
if (!isExpression(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const withoutPrefix = value.substring(1); // Remove =
|
||||
|
||||
// Check if it's wrapped in {{ }}
|
||||
const match = withoutPrefix.match(/^\{\{(.+)\}\}$/s);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
return withoutPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value is a mixed content expression
|
||||
*
|
||||
* Mixed content has both literal text and expressions:
|
||||
* - `Hello {{ $json.name }}!`
|
||||
* - `https://api.com/{{ $json.id }}/data`
|
||||
*
|
||||
* @param value - The value to check
|
||||
* @returns true if the value has mixed content
|
||||
*/
|
||||
export function hasMixedContent(value: unknown): boolean {
|
||||
// Type guard first to avoid calling containsExpression on non-strings
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!containsExpression(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's wrapped entirely in {{ }}, it's not mixed
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('={{') && trimmed.endsWith('}}')) {
|
||||
// Check if there's only one pair of {{ }}
|
||||
const count = (trimmed.match(/\{\{/g) || []).length;
|
||||
if (count === 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user