This is page 28 of 59. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── _config.yml ├── .claude │ └── agents │ ├── code-reviewer.md │ ├── context-manager.md │ ├── debugger.md │ ├── deployment-engineer.md │ ├── mcp-backend-engineer.md │ ├── n8n-mcp-tester.md │ ├── technical-researcher.md │ └── test-automator.md ├── .dockerignore ├── .env.docker ├── .env.example ├── .env.n8n.example ├── .env.test ├── .env.test.example ├── .github │ ├── ABOUT.md │ ├── BENCHMARK_THRESHOLDS.md │ ├── FUNDING.yml │ ├── gh-pages.yml │ ├── secret_scanning.yml │ └── workflows │ ├── benchmark-pr.yml │ ├── benchmark.yml │ ├── docker-build-fast.yml │ ├── docker-build-n8n.yml │ ├── docker-build.yml │ ├── release.yml │ ├── test.yml │ └── update-n8n-deps.yml ├── .gitignore ├── .npmignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CLAUDE.md ├── codecov.yml ├── coverage.json ├── data │ ├── .gitkeep │ ├── nodes.db │ ├── nodes.db-shm │ ├── nodes.db-wal │ └── templates.db ├── deploy │ └── quick-deploy-n8n.sh ├── docker │ ├── docker-entrypoint.sh │ ├── n8n-mcp │ ├── parse-config.js │ └── README.md ├── docker-compose.buildkit.yml ├── docker-compose.extract.yml ├── docker-compose.n8n.yml ├── docker-compose.override.yml.example ├── docker-compose.test-n8n.yml ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.railway ├── Dockerfile.test ├── docs │ ├── AUTOMATED_RELEASES.md │ ├── BENCHMARKS.md │ ├── CHANGELOG.md │ ├── CLAUDE_CODE_SETUP.md │ ├── CLAUDE_INTERVIEW.md │ ├── CODECOV_SETUP.md │ ├── CODEX_SETUP.md │ ├── CURSOR_SETUP.md │ ├── DEPENDENCY_UPDATES.md │ ├── DOCKER_README.md │ ├── DOCKER_TROUBLESHOOTING.md │ ├── FINAL_AI_VALIDATION_SPEC.md │ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md │ ├── HTTP_DEPLOYMENT.md │ ├── img │ │ ├── cc_command.png │ │ ├── cc_connected.png │ │ ├── codex_connected.png │ │ ├── cursor_tut.png │ │ ├── Railway_api.png │ │ ├── Railway_server_address.png │ │ ├── vsc_ghcp_chat_agent_mode.png │ │ ├── vsc_ghcp_chat_instruction_files.png │ │ ├── vsc_ghcp_chat_thinking_tool.png │ │ └── windsurf_tut.png │ ├── INSTALLATION.md │ ├── LIBRARY_USAGE.md │ ├── local │ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md │ │ ├── DEEP_DIVE_ANALYSIS_README.md │ │ ├── Deep_dive_p1_p2.md │ │ ├── integration-testing-plan.md │ │ ├── integration-tests-phase1-summary.md │ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md │ │ ├── P0_IMPLEMENTATION_PLAN.md │ │ └── TEMPLATE_MINING_ANALYSIS.md │ ├── MCP_ESSENTIALS_README.md │ ├── MCP_QUICK_START_GUIDE.md │ ├── N8N_DEPLOYMENT.md │ ├── RAILWAY_DEPLOYMENT.md │ ├── README_CLAUDE_SETUP.md │ ├── README.md │ ├── tools-documentation-usage.md │ ├── VS_CODE_PROJECT_SETUP.md │ ├── WINDSURF_SETUP.md │ └── workflow-diff-examples.md ├── examples │ └── enhanced-documentation-demo.js ├── fetch_log.txt ├── LICENSE ├── MEMORY_N8N_UPDATE.md ├── MEMORY_TEMPLATE_UPDATE.md ├── monitor_fetch.sh ├── N8N_HTTP_STREAMABLE_SETUP.md ├── n8n-nodes.db ├── P0-R3-TEST-PLAN.md ├── package-lock.json ├── package.json ├── package.runtime.json ├── PRIVACY.md ├── railway.json ├── README.md ├── renovate.json ├── scripts │ ├── analyze-optimization.sh │ ├── audit-schema-coverage.ts │ ├── build-optimized.sh │ ├── compare-benchmarks.js │ ├── demo-optimization.sh │ ├── deploy-http.sh │ ├── deploy-to-vm.sh │ ├── export-webhook-workflows.ts │ ├── extract-changelog.js │ ├── extract-from-docker.js │ ├── extract-nodes-docker.sh │ ├── extract-nodes-simple.sh │ ├── format-benchmark-results.js │ ├── generate-benchmark-stub.js │ ├── generate-detailed-reports.js │ ├── generate-test-summary.js │ ├── http-bridge.js │ ├── mcp-http-client.js │ ├── migrate-nodes-fts.ts │ ├── migrate-tool-docs.ts │ ├── n8n-docs-mcp.service │ ├── nginx-n8n-mcp.conf │ ├── prebuild-fts5.ts │ ├── prepare-release.js │ ├── publish-npm-quick.sh │ ├── publish-npm.sh │ ├── quick-test.ts │ ├── run-benchmarks-ci.js │ ├── sync-runtime-version.js │ ├── test-ai-validation-debug.ts │ ├── test-code-node-enhancements.ts │ ├── test-code-node-fixes.ts │ ├── test-docker-config.sh │ ├── test-docker-fingerprint.ts │ ├── test-docker-optimization.sh │ ├── test-docker.sh │ ├── test-empty-connection-validation.ts │ ├── test-error-message-tracking.ts │ ├── test-error-output-validation.ts │ ├── test-error-validation.js │ ├── test-essentials.ts │ ├── test-expression-code-validation.ts │ ├── test-expression-format-validation.js │ ├── test-fts5-search.ts │ ├── test-fuzzy-fix.ts │ ├── test-fuzzy-simple.ts │ ├── test-helpers-validation.ts │ ├── test-http-search.ts │ ├── test-http.sh │ ├── test-jmespath-validation.ts │ ├── test-multi-tenant-simple.ts │ ├── test-multi-tenant.ts │ ├── test-n8n-integration.sh │ ├── test-node-info.js │ ├── test-node-type-validation.ts │ ├── test-nodes-base-prefix.ts │ ├── test-operation-validation.ts │ ├── test-optimized-docker.sh │ ├── test-release-automation.js │ ├── test-search-improvements.ts │ ├── test-security.ts │ ├── test-single-session.sh │ ├── test-sqljs-triggers.ts │ ├── test-telemetry-debug.ts │ ├── test-telemetry-direct.ts │ ├── test-telemetry-env.ts │ ├── test-telemetry-integration.ts │ ├── test-telemetry-no-select.ts │ ├── test-telemetry-security.ts │ ├── test-telemetry-simple.ts │ ├── test-typeversion-validation.ts │ ├── test-url-configuration.ts │ ├── test-user-id-persistence.ts │ ├── test-webhook-validation.ts │ ├── test-workflow-insert.ts │ ├── test-workflow-sanitizer.ts │ ├── test-workflow-tracking-debug.ts │ ├── update-and-publish-prep.sh │ ├── update-n8n-deps.js │ ├── update-readme-version.js │ ├── vitest-benchmark-json-reporter.js │ └── vitest-benchmark-reporter.ts ├── SECURITY.md ├── src │ ├── config │ │ └── n8n-api.ts │ ├── data │ │ └── canonical-ai-tool-examples.json │ ├── database │ │ ├── database-adapter.ts │ │ ├── migrations │ │ │ └── add-template-node-configs.sql │ │ ├── node-repository.ts │ │ ├── nodes.db │ │ ├── schema-optimized.sql │ │ └── schema.sql │ ├── errors │ │ └── validation-service-error.ts │ ├── http-server-single-session.ts │ ├── http-server.ts │ ├── index.ts │ ├── loaders │ │ └── node-loader.ts │ ├── mappers │ │ └── docs-mapper.ts │ ├── mcp │ │ ├── handlers-n8n-manager.ts │ │ ├── handlers-workflow-diff.ts │ │ ├── index.ts │ │ ├── server.ts │ │ ├── stdio-wrapper.ts │ │ ├── tool-docs │ │ │ ├── configuration │ │ │ │ ├── get-node-as-tool-info.ts │ │ │ │ ├── get-node-documentation.ts │ │ │ │ ├── get-node-essentials.ts │ │ │ │ ├── get-node-info.ts │ │ │ │ ├── get-property-dependencies.ts │ │ │ │ ├── index.ts │ │ │ │ └── search-node-properties.ts │ │ │ ├── discovery │ │ │ │ ├── get-database-statistics.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-ai-tools.ts │ │ │ │ ├── list-nodes.ts │ │ │ │ └── search-nodes.ts │ │ │ ├── guides │ │ │ │ ├── ai-agents-guide.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── system │ │ │ │ ├── index.ts │ │ │ │ ├── n8n-diagnostic.ts │ │ │ │ ├── n8n-health-check.ts │ │ │ │ ├── n8n-list-available-tools.ts │ │ │ │ └── tools-documentation.ts │ │ │ ├── templates │ │ │ │ ├── get-template.ts │ │ │ │ ├── get-templates-for-task.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-node-templates.ts │ │ │ │ ├── list-tasks.ts │ │ │ │ ├── search-templates-by-metadata.ts │ │ │ │ └── search-templates.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ ├── validate-node-minimal.ts │ │ │ │ ├── validate-node-operation.ts │ │ │ │ ├── validate-workflow-connections.ts │ │ │ │ ├── validate-workflow-expressions.ts │ │ │ │ └── validate-workflow.ts │ │ │ └── workflow_management │ │ │ ├── index.ts │ │ │ ├── n8n-autofix-workflow.ts │ │ │ ├── n8n-create-workflow.ts │ │ │ ├── n8n-delete-execution.ts │ │ │ ├── n8n-delete-workflow.ts │ │ │ ├── n8n-get-execution.ts │ │ │ ├── n8n-get-workflow-details.ts │ │ │ ├── n8n-get-workflow-minimal.ts │ │ │ ├── n8n-get-workflow-structure.ts │ │ │ ├── n8n-get-workflow.ts │ │ │ ├── n8n-list-executions.ts │ │ │ ├── n8n-list-workflows.ts │ │ │ ├── n8n-trigger-webhook-workflow.ts │ │ │ ├── n8n-update-full-workflow.ts │ │ │ ├── n8n-update-partial-workflow.ts │ │ │ └── n8n-validate-workflow.ts │ │ ├── tools-documentation.ts │ │ ├── tools-n8n-friendly.ts │ │ ├── tools-n8n-manager.ts │ │ ├── tools.ts │ │ └── workflow-examples.ts │ ├── mcp-engine.ts │ ├── mcp-tools-engine.ts │ ├── n8n │ │ ├── MCPApi.credentials.ts │ │ └── MCPNode.node.ts │ ├── parsers │ │ ├── node-parser.ts │ │ ├── property-extractor.ts │ │ └── simple-parser.ts │ ├── scripts │ │ ├── debug-http-search.ts │ │ ├── extract-from-docker.ts │ │ ├── fetch-templates-robust.ts │ │ ├── fetch-templates.ts │ │ ├── rebuild-database.ts │ │ ├── rebuild-optimized.ts │ │ ├── rebuild.ts │ │ ├── sanitize-templates.ts │ │ ├── seed-canonical-ai-examples.ts │ │ ├── test-autofix-documentation.ts │ │ ├── test-autofix-workflow.ts │ │ ├── test-execution-filtering.ts │ │ ├── test-node-suggestions.ts │ │ ├── test-protocol-negotiation.ts │ │ ├── test-summary.ts │ │ ├── test-webhook-autofix.ts │ │ ├── validate.ts │ │ └── validation-summary.ts │ ├── services │ │ ├── ai-node-validator.ts │ │ ├── ai-tool-validators.ts │ │ ├── confidence-scorer.ts │ │ ├── config-validator.ts │ │ ├── enhanced-config-validator.ts │ │ ├── example-generator.ts │ │ ├── execution-processor.ts │ │ ├── expression-format-validator.ts │ │ ├── expression-validator.ts │ │ ├── n8n-api-client.ts │ │ ├── n8n-validation.ts │ │ ├── node-documentation-service.ts │ │ ├── node-sanitizer.ts │ │ ├── node-similarity-service.ts │ │ ├── node-specific-validators.ts │ │ ├── operation-similarity-service.ts │ │ ├── property-dependencies.ts │ │ ├── property-filter.ts │ │ ├── resource-similarity-service.ts │ │ ├── sqlite-storage-service.ts │ │ ├── task-templates.ts │ │ ├── universal-expression-validator.ts │ │ ├── workflow-auto-fixer.ts │ │ ├── workflow-diff-engine.ts │ │ └── workflow-validator.ts │ ├── telemetry │ │ ├── batch-processor.ts │ │ ├── config-manager.ts │ │ ├── early-error-logger.ts │ │ ├── error-sanitization-utils.ts │ │ ├── error-sanitizer.ts │ │ ├── event-tracker.ts │ │ ├── event-validator.ts │ │ ├── index.ts │ │ ├── performance-monitor.ts │ │ ├── rate-limiter.ts │ │ ├── startup-checkpoints.ts │ │ ├── telemetry-error.ts │ │ ├── telemetry-manager.ts │ │ ├── telemetry-types.ts │ │ └── workflow-sanitizer.ts │ ├── templates │ │ ├── batch-processor.ts │ │ ├── metadata-generator.ts │ │ ├── README.md │ │ ├── template-fetcher.ts │ │ ├── template-repository.ts │ │ └── template-service.ts │ ├── types │ │ ├── index.ts │ │ ├── instance-context.ts │ │ ├── n8n-api.ts │ │ ├── node-types.ts │ │ └── workflow-diff.ts │ └── utils │ ├── auth.ts │ ├── bridge.ts │ ├── cache-utils.ts │ ├── console-manager.ts │ ├── documentation-fetcher.ts │ ├── enhanced-documentation-fetcher.ts │ ├── error-handler.ts │ ├── example-generator.ts │ ├── fixed-collection-validator.ts │ ├── logger.ts │ ├── mcp-client.ts │ ├── n8n-errors.ts │ ├── node-source-extractor.ts │ ├── node-type-normalizer.ts │ ├── node-type-utils.ts │ ├── node-utils.ts │ ├── npm-version-checker.ts │ ├── protocol-version.ts │ ├── simple-cache.ts │ ├── ssrf-protection.ts │ ├── template-node-resolver.ts │ ├── template-sanitizer.ts │ ├── url-detector.ts │ ├── validation-schemas.ts │ └── version.ts ├── test-output.txt ├── test-reinit-fix.sh ├── tests │ ├── __snapshots__ │ │ └── .gitkeep │ ├── auth.test.ts │ ├── benchmarks │ │ ├── database-queries.bench.ts │ │ ├── index.ts │ │ ├── mcp-tools.bench.ts │ │ ├── mcp-tools.bench.ts.disabled │ │ ├── mcp-tools.bench.ts.skip │ │ ├── node-loading.bench.ts.disabled │ │ ├── README.md │ │ ├── search-operations.bench.ts.disabled │ │ └── validation-performance.bench.ts.disabled │ ├── bridge.test.ts │ ├── comprehensive-extraction-test.js │ ├── data │ │ └── .gitkeep │ ├── debug-slack-doc.js │ ├── demo-enhanced-documentation.js │ ├── docker-tests-README.md │ ├── error-handler.test.ts │ ├── examples │ │ └── using-database-utils.test.ts │ ├── extracted-nodes-db │ │ ├── database-import.json │ │ ├── extraction-report.json │ │ ├── insert-nodes.sql │ │ ├── n8n-nodes-base__Airtable.json │ │ ├── n8n-nodes-base__Discord.json │ │ ├── n8n-nodes-base__Function.json │ │ ├── n8n-nodes-base__HttpRequest.json │ │ ├── n8n-nodes-base__If.json │ │ ├── n8n-nodes-base__Slack.json │ │ ├── n8n-nodes-base__SplitInBatches.json │ │ └── n8n-nodes-base__Webhook.json │ ├── factories │ │ ├── node-factory.ts │ │ └── property-definition-factory.ts │ ├── fixtures │ │ ├── .gitkeep │ │ ├── database │ │ │ └── test-nodes.json │ │ ├── factories │ │ │ ├── node.factory.ts │ │ │ └── parser-node.factory.ts │ │ └── template-configs.ts │ ├── helpers │ │ └── env-helpers.ts │ ├── http-server-auth.test.ts │ ├── integration │ │ ├── ai-validation │ │ │ ├── ai-agent-validation.test.ts │ │ │ ├── ai-tool-validation.test.ts │ │ │ ├── chat-trigger-validation.test.ts │ │ │ ├── e2e-validation.test.ts │ │ │ ├── helpers.ts │ │ │ ├── llm-chain-validation.test.ts │ │ │ ├── README.md │ │ │ └── TEST_REPORT.md │ │ ├── ci │ │ │ └── database-population.test.ts │ │ ├── database │ │ │ ├── connection-management.test.ts │ │ │ ├── empty-database.test.ts │ │ │ ├── fts5-search.test.ts │ │ │ ├── node-fts5-search.test.ts │ │ │ ├── node-repository.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── sqljs-memory-leak.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-sanitizer.test.ts │ │ │ ├── node-similarity-service.test.ts │ │ │ ├── node-specific-validators.test.ts │ │ │ ├── operation-similarity-service-comprehensive.test.ts │ │ │ ├── operation-similarity-service.test.ts │ │ │ ├── property-dependencies.test.ts │ │ │ ├── property-filter-edge-cases.test.ts │ │ │ ├── property-filter.test.ts │ │ │ ├── resource-similarity-service-comprehensive.test.ts │ │ │ ├── resource-similarity-service.test.ts │ │ │ ├── task-templates.test.ts │ │ │ ├── template-service.test.ts │ │ │ ├── universal-expression-validator.test.ts │ │ │ ├── validation-fixes.test.ts │ │ │ ├── workflow-auto-fixer.test.ts │ │ │ ├── workflow-diff-engine.test.ts │ │ │ ├── workflow-fixed-collection-validation.test.ts │ │ │ ├── workflow-validator-comprehensive.test.ts │ │ │ ├── workflow-validator-edge-cases.test.ts │ │ │ ├── workflow-validator-error-outputs.test.ts │ │ │ ├── workflow-validator-expression-format.test.ts │ │ │ ├── workflow-validator-loops-simple.test.ts │ │ │ ├── workflow-validator-loops.test.ts │ │ │ ├── workflow-validator-mocks.test.ts │ │ │ ├── workflow-validator-performance.test.ts │ │ │ ├── workflow-validator-with-mocks.test.ts │ │ │ └── workflow-validator.test.ts │ │ ├── telemetry │ │ │ ├── batch-processor.test.ts │ │ │ ├── config-manager.test.ts │ │ │ ├── event-tracker.test.ts │ │ │ ├── event-validator.test.ts │ │ │ ├── rate-limiter.test.ts │ │ │ ├── telemetry-error.test.ts │ │ │ ├── telemetry-manager.test.ts │ │ │ ├── v2.18.3-fixes-verification.test.ts │ │ │ └── workflow-sanitizer.test.ts │ │ ├── templates │ │ │ ├── batch-processor.test.ts │ │ │ ├── metadata-generator.test.ts │ │ │ ├── template-repository-metadata.test.ts │ │ │ └── template-repository-security.test.ts │ │ ├── test-env-example.test.ts │ │ ├── test-infrastructure.test.ts │ │ ├── types │ │ │ ├── instance-context-coverage.test.ts │ │ │ └── instance-context-multi-tenant.test.ts │ │ ├── utils │ │ │ ├── auth-timing-safe.test.ts │ │ │ ├── cache-utils.test.ts │ │ │ ├── console-manager.test.ts │ │ │ ├── database-utils.test.ts │ │ │ ├── fixed-collection-validator.test.ts │ │ │ ├── n8n-errors.test.ts │ │ │ ├── node-type-normalizer.test.ts │ │ │ ├── node-type-utils.test.ts │ │ │ ├── node-utils.test.ts │ │ │ ├── simple-cache-memory-leak-fix.test.ts │ │ │ ├── ssrf-protection.test.ts │ │ │ └── template-node-resolver.test.ts │ │ └── validation-fixes.test.ts │ └── utils │ ├── assertions.ts │ ├── builders │ │ └── workflow.builder.ts │ ├── data-generators.ts │ ├── database-utils.ts │ ├── README.md │ └── test-helpers.ts ├── thumbnail.png ├── tsconfig.build.json ├── tsconfig.json ├── types │ ├── mcp.d.ts │ └── test-env.d.ts ├── verify-telemetry-fix.js ├── versioned-nodes.md ├── vitest.config.benchmark.ts ├── vitest.config.integration.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /src/http-server.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | /** 3 | * Fixed HTTP server for n8n-MCP that properly handles StreamableHTTPServerTransport initialization 4 | * This implementation ensures the transport is properly initialized before handling requests 5 | */ 6 | import express from 'express'; 7 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 8 | import { n8nDocumentationToolsFinal } from './mcp/tools'; 9 | import { n8nManagementTools } from './mcp/tools-n8n-manager'; 10 | import { N8NDocumentationMCPServer } from './mcp/server'; 11 | import { logger } from './utils/logger'; 12 | import { AuthManager } from './utils/auth'; 13 | import { PROJECT_VERSION } from './utils/version'; 14 | import { isN8nApiConfigured } from './config/n8n-api'; 15 | import dotenv from 'dotenv'; 16 | import { readFileSync } from 'fs'; 17 | import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector'; 18 | import { 19 | negotiateProtocolVersion, 20 | logProtocolNegotiation, 21 | N8N_PROTOCOL_VERSION 22 | } from './utils/protocol-version'; 23 | 24 | dotenv.config(); 25 | 26 | let expressServer: any; 27 | let authToken: string | null = null; 28 | 29 | /** 30 | * Load auth token from environment variable or file 31 | */ 32 | export function loadAuthToken(): string | null { 33 | // First, try AUTH_TOKEN environment variable 34 | if (process.env.AUTH_TOKEN) { 35 | logger.info('Using AUTH_TOKEN from environment variable'); 36 | return process.env.AUTH_TOKEN; 37 | } 38 | 39 | // Then, try AUTH_TOKEN_FILE 40 | if (process.env.AUTH_TOKEN_FILE) { 41 | try { 42 | const token = readFileSync(process.env.AUTH_TOKEN_FILE, 'utf-8').trim(); 43 | logger.info(`Loaded AUTH_TOKEN from file: ${process.env.AUTH_TOKEN_FILE}`); 44 | return token; 45 | } catch (error) { 46 | logger.error(`Failed to read AUTH_TOKEN_FILE: ${process.env.AUTH_TOKEN_FILE}`, error); 47 | console.error(`ERROR: Failed to read AUTH_TOKEN_FILE: ${process.env.AUTH_TOKEN_FILE}`); 48 | console.error(error instanceof Error ? error.message : 'Unknown error'); 49 | return null; 50 | } 51 | } 52 | 53 | return null; 54 | } 55 | 56 | /** 57 | * Validate required environment variables 58 | */ 59 | function validateEnvironment() { 60 | // Load auth token from env var or file 61 | authToken = loadAuthToken(); 62 | 63 | if (!authToken || authToken.trim() === '') { 64 | logger.error('No authentication token found or token is empty'); 65 | console.error('ERROR: AUTH_TOKEN is required for HTTP mode and cannot be empty'); 66 | console.error('Set AUTH_TOKEN environment variable or AUTH_TOKEN_FILE pointing to a file containing the token'); 67 | console.error('Generate AUTH_TOKEN with: openssl rand -base64 32'); 68 | process.exit(1); 69 | } 70 | 71 | // Update authToken to trimmed version 72 | authToken = authToken.trim(); 73 | 74 | if (authToken.length < 32) { 75 | logger.warn('AUTH_TOKEN should be at least 32 characters for security'); 76 | console.warn('WARNING: AUTH_TOKEN should be at least 32 characters for security'); 77 | } 78 | 79 | // Check for default token and show prominent warnings 80 | if (authToken === 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh') { 81 | logger.warn('⚠️ SECURITY WARNING: Using default AUTH_TOKEN - CHANGE IMMEDIATELY!'); 82 | logger.warn('Generate secure token with: openssl rand -base64 32'); 83 | 84 | // Only show console warnings in HTTP mode 85 | if (process.env.MCP_MODE === 'http') { 86 | console.warn('\n⚠️ SECURITY WARNING ⚠️'); 87 | console.warn('Using default AUTH_TOKEN - CHANGE IMMEDIATELY!'); 88 | console.warn('Generate secure token: openssl rand -base64 32'); 89 | console.warn('Update via Railway dashboard environment variables\n'); 90 | } 91 | } 92 | } 93 | 94 | /** 95 | * Graceful shutdown handler 96 | */ 97 | async function shutdown() { 98 | logger.info('Shutting down HTTP server...'); 99 | console.log('Shutting down HTTP server...'); 100 | 101 | if (expressServer) { 102 | expressServer.close(() => { 103 | logger.info('HTTP server closed'); 104 | console.log('HTTP server closed'); 105 | process.exit(0); 106 | }); 107 | 108 | setTimeout(() => { 109 | logger.error('Forced shutdown after timeout'); 110 | process.exit(1); 111 | }, 10000); 112 | } else { 113 | process.exit(0); 114 | } 115 | } 116 | 117 | export async function startFixedHTTPServer() { 118 | validateEnvironment(); 119 | 120 | const app = express(); 121 | 122 | // Configure trust proxy for correct IP logging behind reverse proxies 123 | const trustProxy = process.env.TRUST_PROXY ? Number(process.env.TRUST_PROXY) : 0; 124 | if (trustProxy > 0) { 125 | app.set('trust proxy', trustProxy); 126 | logger.info(`Trust proxy enabled with ${trustProxy} hop(s)`); 127 | } 128 | 129 | // CRITICAL: Don't use any body parser - StreamableHTTPServerTransport needs raw stream 130 | 131 | // Security headers 132 | app.use((req, res, next) => { 133 | res.setHeader('X-Content-Type-Options', 'nosniff'); 134 | res.setHeader('X-Frame-Options', 'DENY'); 135 | res.setHeader('X-XSS-Protection', '1; mode=block'); 136 | res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); 137 | next(); 138 | }); 139 | 140 | // CORS configuration 141 | app.use((req, res, next) => { 142 | const allowedOrigin = process.env.CORS_ORIGIN || '*'; 143 | res.setHeader('Access-Control-Allow-Origin', allowedOrigin); 144 | res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); 145 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept'); 146 | res.setHeader('Access-Control-Max-Age', '86400'); 147 | 148 | if (req.method === 'OPTIONS') { 149 | res.sendStatus(204); 150 | return; 151 | } 152 | next(); 153 | }); 154 | 155 | // Request logging 156 | app.use((req, res, next) => { 157 | logger.info(`${req.method} ${req.path}`, { 158 | ip: req.ip, 159 | userAgent: req.get('user-agent'), 160 | contentLength: req.get('content-length') 161 | }); 162 | next(); 163 | }); 164 | 165 | // Create a single persistent MCP server instance 166 | const mcpServer = new N8NDocumentationMCPServer(); 167 | logger.info('Created persistent MCP server instance'); 168 | 169 | // Root endpoint with API information 170 | app.get('/', (req, res) => { 171 | const port = parseInt(process.env.PORT || '3000'); 172 | const host = process.env.HOST || '0.0.0.0'; 173 | const baseUrl = detectBaseUrl(req, host, port); 174 | const endpoints = formatEndpointUrls(baseUrl); 175 | 176 | res.json({ 177 | name: 'n8n Documentation MCP Server', 178 | version: PROJECT_VERSION, 179 | description: 'Model Context Protocol server providing comprehensive n8n node documentation and workflow management', 180 | endpoints: { 181 | health: { 182 | url: endpoints.health, 183 | method: 'GET', 184 | description: 'Health check and status information' 185 | }, 186 | mcp: { 187 | url: endpoints.mcp, 188 | method: 'GET/POST', 189 | description: 'MCP endpoint - GET for info, POST for JSON-RPC' 190 | } 191 | }, 192 | authentication: { 193 | type: 'Bearer Token', 194 | header: 'Authorization: Bearer <token>', 195 | required_for: ['POST /mcp'] 196 | }, 197 | documentation: 'https://github.com/czlonkowski/n8n-mcp' 198 | }); 199 | }); 200 | 201 | // Health check endpoint 202 | app.get('/health', (req, res) => { 203 | res.json({ 204 | status: 'ok', 205 | mode: 'http-fixed', 206 | version: PROJECT_VERSION, 207 | uptime: Math.floor(process.uptime()), 208 | memory: { 209 | used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), 210 | total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), 211 | unit: 'MB' 212 | }, 213 | timestamp: new Date().toISOString() 214 | }); 215 | }); 216 | 217 | // Version endpoint 218 | app.get('/version', (req, res) => { 219 | res.json({ 220 | version: PROJECT_VERSION, 221 | buildTime: new Date().toISOString(), 222 | tools: n8nDocumentationToolsFinal.map(t => t.name), 223 | commit: process.env.GIT_COMMIT || 'unknown' 224 | }); 225 | }); 226 | 227 | // Test tools endpoint 228 | app.get('/test-tools', async (req, res) => { 229 | try { 230 | const result = await mcpServer.executeTool('get_node_essentials', { nodeType: 'nodes-base.httpRequest' }); 231 | res.json({ status: 'ok', hasData: !!result, toolCount: n8nDocumentationToolsFinal.length }); 232 | } catch (error) { 233 | res.json({ status: 'error', message: error instanceof Error ? error.message : 'Unknown error' }); 234 | } 235 | }); 236 | 237 | // MCP information endpoint (no auth required for discovery) 238 | app.get('/mcp', (req, res) => { 239 | res.json({ 240 | description: 'n8n Documentation MCP Server', 241 | version: PROJECT_VERSION, 242 | endpoints: { 243 | mcp: { 244 | method: 'POST', 245 | path: '/mcp', 246 | description: 'Main MCP JSON-RPC endpoint', 247 | authentication: 'Bearer token required' 248 | }, 249 | health: { 250 | method: 'GET', 251 | path: '/health', 252 | description: 'Health check endpoint', 253 | authentication: 'None' 254 | }, 255 | root: { 256 | method: 'GET', 257 | path: '/', 258 | description: 'API information', 259 | authentication: 'None' 260 | } 261 | }, 262 | documentation: 'https://github.com/czlonkowski/n8n-mcp' 263 | }); 264 | }); 265 | 266 | // Main MCP endpoint - handle each request with custom transport handling 267 | app.post('/mcp', async (req: express.Request, res: express.Response): Promise<void> => { 268 | const startTime = Date.now(); 269 | 270 | // Enhanced authentication check with specific logging 271 | const authHeader = req.headers.authorization; 272 | 273 | // Check if Authorization header is missing 274 | if (!authHeader) { 275 | logger.warn('Authentication failed: Missing Authorization header', { 276 | ip: req.ip, 277 | userAgent: req.get('user-agent'), 278 | reason: 'no_auth_header' 279 | }); 280 | res.status(401).json({ 281 | jsonrpc: '2.0', 282 | error: { 283 | code: -32001, 284 | message: 'Unauthorized' 285 | }, 286 | id: null 287 | }); 288 | return; 289 | } 290 | 291 | // Check if Authorization header has Bearer prefix 292 | if (!authHeader.startsWith('Bearer ')) { 293 | logger.warn('Authentication failed: Invalid Authorization header format (expected Bearer token)', { 294 | ip: req.ip, 295 | userAgent: req.get('user-agent'), 296 | reason: 'invalid_auth_format', 297 | headerPrefix: authHeader.substring(0, Math.min(authHeader.length, 10)) + '...' // Log first 10 chars for debugging 298 | }); 299 | res.status(401).json({ 300 | jsonrpc: '2.0', 301 | error: { 302 | code: -32001, 303 | message: 'Unauthorized' 304 | }, 305 | id: null 306 | }); 307 | return; 308 | } 309 | 310 | // Extract token and trim whitespace 311 | const token = authHeader.slice(7).trim(); 312 | 313 | // SECURITY: Use timing-safe comparison to prevent timing attacks 314 | // See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-02) 315 | const isValidToken = authToken && 316 | AuthManager.timingSafeCompare(token, authToken); 317 | 318 | if (!isValidToken) { 319 | logger.warn('Authentication failed: Invalid token', { 320 | ip: req.ip, 321 | userAgent: req.get('user-agent'), 322 | reason: 'invalid_token' 323 | }); 324 | res.status(401).json({ 325 | jsonrpc: '2.0', 326 | error: { 327 | code: -32001, 328 | message: 'Unauthorized' 329 | }, 330 | id: null 331 | }); 332 | return; 333 | } 334 | 335 | try { 336 | // Instead of using StreamableHTTPServerTransport, we'll handle the request directly 337 | // This avoids the initialization issues with the transport 338 | 339 | // Collect the raw body 340 | let body = ''; 341 | req.on('data', chunk => { 342 | body += chunk.toString(); 343 | }); 344 | 345 | req.on('end', async () => { 346 | try { 347 | const jsonRpcRequest = JSON.parse(body); 348 | logger.debug('Received JSON-RPC request:', { method: jsonRpcRequest.method }); 349 | 350 | // Handle the request based on method 351 | let response; 352 | 353 | switch (jsonRpcRequest.method) { 354 | case 'initialize': 355 | // Negotiate protocol version for this client/request 356 | const negotiationResult = negotiateProtocolVersion( 357 | jsonRpcRequest.params?.protocolVersion, 358 | jsonRpcRequest.params?.clientInfo, 359 | req.get('user-agent'), 360 | req.headers 361 | ); 362 | 363 | logProtocolNegotiation(negotiationResult, logger, 'HTTP_SERVER_INITIALIZE'); 364 | 365 | response = { 366 | jsonrpc: '2.0', 367 | result: { 368 | protocolVersion: negotiationResult.version, 369 | capabilities: { 370 | tools: {}, 371 | resources: {} 372 | }, 373 | serverInfo: { 374 | name: 'n8n-documentation-mcp', 375 | version: PROJECT_VERSION 376 | } 377 | }, 378 | id: jsonRpcRequest.id 379 | }; 380 | break; 381 | 382 | case 'tools/list': 383 | // Use the proper tool list that includes management tools when configured 384 | const tools = [...n8nDocumentationToolsFinal]; 385 | 386 | // Add management tools if n8n API is configured 387 | if (isN8nApiConfigured()) { 388 | tools.push(...n8nManagementTools); 389 | } 390 | 391 | response = { 392 | jsonrpc: '2.0', 393 | result: { 394 | tools 395 | }, 396 | id: jsonRpcRequest.id 397 | }; 398 | break; 399 | 400 | case 'tools/call': 401 | // Delegate to the MCP server 402 | const toolName = jsonRpcRequest.params?.name; 403 | const toolArgs = jsonRpcRequest.params?.arguments || {}; 404 | 405 | try { 406 | const result = await mcpServer.executeTool(toolName, toolArgs); 407 | response = { 408 | jsonrpc: '2.0', 409 | result: { 410 | content: [ 411 | { 412 | type: 'text', 413 | text: JSON.stringify(result, null, 2) 414 | } 415 | ] 416 | }, 417 | id: jsonRpcRequest.id 418 | }; 419 | } catch (error) { 420 | response = { 421 | jsonrpc: '2.0', 422 | error: { 423 | code: -32603, 424 | message: `Error executing tool ${toolName}: ${error instanceof Error ? error.message : 'Unknown error'}` 425 | }, 426 | id: jsonRpcRequest.id 427 | }; 428 | } 429 | break; 430 | 431 | default: 432 | response = { 433 | jsonrpc: '2.0', 434 | error: { 435 | code: -32601, 436 | message: `Method not found: ${jsonRpcRequest.method}` 437 | }, 438 | id: jsonRpcRequest.id 439 | }; 440 | } 441 | 442 | // Send response 443 | res.setHeader('Content-Type', 'application/json'); 444 | res.json(response); 445 | 446 | const duration = Date.now() - startTime; 447 | logger.info('MCP request completed', { 448 | duration, 449 | method: jsonRpcRequest.method 450 | }); 451 | } catch (error) { 452 | logger.error('Error processing request:', error); 453 | res.status(400).json({ 454 | jsonrpc: '2.0', 455 | error: { 456 | code: -32700, 457 | message: 'Parse error', 458 | data: error instanceof Error ? error.message : 'Unknown error' 459 | }, 460 | id: null 461 | }); 462 | } 463 | }); 464 | } catch (error) { 465 | logger.error('MCP request error:', error); 466 | 467 | if (!res.headersSent) { 468 | res.status(500).json({ 469 | jsonrpc: '2.0', 470 | error: { 471 | code: -32603, 472 | message: 'Internal server error', 473 | data: process.env.NODE_ENV === 'development' 474 | ? (error as Error).message 475 | : undefined 476 | }, 477 | id: null 478 | }); 479 | } 480 | } 481 | }); 482 | 483 | // 404 handler 484 | app.use((req, res) => { 485 | res.status(404).json({ 486 | error: 'Not found', 487 | message: `Cannot ${req.method} ${req.path}` 488 | }); 489 | }); 490 | 491 | // Error handler 492 | app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { 493 | logger.error('Express error handler:', err); 494 | 495 | if (!res.headersSent) { 496 | res.status(500).json({ 497 | jsonrpc: '2.0', 498 | error: { 499 | code: -32603, 500 | message: 'Internal server error', 501 | data: process.env.NODE_ENV === 'development' ? err.message : undefined 502 | }, 503 | id: null 504 | }); 505 | } 506 | }); 507 | 508 | const port = parseInt(process.env.PORT || '3000'); 509 | const host = process.env.HOST || '0.0.0.0'; 510 | 511 | expressServer = app.listen(port, host, () => { 512 | logger.info(`n8n MCP Fixed HTTP Server started`, { port, host }); 513 | 514 | // Detect the base URL using our utility 515 | const baseUrl = getStartupBaseUrl(host, port); 516 | const endpoints = formatEndpointUrls(baseUrl); 517 | 518 | console.log(`n8n MCP Fixed HTTP Server running on ${host}:${port}`); 519 | console.log(`Health check: ${endpoints.health}`); 520 | console.log(`MCP endpoint: ${endpoints.mcp}`); 521 | console.log('\nPress Ctrl+C to stop the server'); 522 | 523 | // Start periodic warning timer if using default token 524 | if (authToken === 'REPLACE_THIS_AUTH_TOKEN_32_CHARS_MIN_abcdefgh') { 525 | setInterval(() => { 526 | logger.warn('⚠️ Still using default AUTH_TOKEN - security risk!'); 527 | if (process.env.MCP_MODE === 'http') { 528 | console.warn('⚠️ REMINDER: Still using default AUTH_TOKEN - please change it!'); 529 | } 530 | }, 300000); // Every 5 minutes 531 | } 532 | 533 | if (process.env.BASE_URL || process.env.PUBLIC_URL) { 534 | console.log(`\nPublic URL configured: ${baseUrl}`); 535 | } else if (process.env.TRUST_PROXY && Number(process.env.TRUST_PROXY) > 0) { 536 | console.log(`\nNote: TRUST_PROXY is enabled. URLs will be auto-detected from proxy headers.`); 537 | } 538 | }); 539 | 540 | // Handle errors 541 | expressServer.on('error', (error: any) => { 542 | if (error.code === 'EADDRINUSE') { 543 | logger.error(`Port ${port} is already in use`); 544 | console.error(`ERROR: Port ${port} is already in use`); 545 | process.exit(1); 546 | } else { 547 | logger.error('Server error:', error); 548 | console.error('Server error:', error); 549 | process.exit(1); 550 | } 551 | }); 552 | 553 | // Graceful shutdown handlers 554 | process.on('SIGTERM', shutdown); 555 | process.on('SIGINT', shutdown); 556 | 557 | // Handle uncaught errors 558 | process.on('uncaughtException', (error) => { 559 | logger.error('Uncaught exception:', error); 560 | console.error('Uncaught exception:', error); 561 | shutdown(); 562 | }); 563 | 564 | process.on('unhandledRejection', (reason, promise) => { 565 | logger.error('Unhandled rejection:', reason); 566 | console.error('Unhandled rejection at:', promise, 'reason:', reason); 567 | shutdown(); 568 | }); 569 | } 570 | 571 | // Make executeTool public on the server 572 | declare module './mcp/server' { 573 | interface N8NDocumentationMCPServer { 574 | executeTool(name: string, args: any): Promise<any>; 575 | } 576 | } 577 | 578 | // Start if called directly 579 | // Check if this file is being run directly (not imported) 580 | // In ES modules, we check import.meta.url against process.argv[1] 581 | // But since we're transpiling to CommonJS, we use the require.main check 582 | if (typeof require !== 'undefined' && require.main === module) { 583 | startFixedHTTPServer().catch(error => { 584 | logger.error('Failed to start Fixed HTTP server:', error); 585 | console.error('Failed to start Fixed HTTP server:', error); 586 | process.exit(1); 587 | }); 588 | } ``` -------------------------------------------------------------------------------- /tests/unit/telemetry/telemetry-error.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 2 | import { TelemetryError, TelemetryCircuitBreaker, TelemetryErrorAggregator } from '../../../src/telemetry/telemetry-error'; 3 | import { TelemetryErrorType } from '../../../src/telemetry/telemetry-types'; 4 | import { logger } from '../../../src/utils/logger'; 5 | 6 | // Mock logger to avoid console output in tests 7 | vi.mock('../../../src/utils/logger', () => ({ 8 | logger: { 9 | debug: vi.fn(), 10 | info: vi.fn(), 11 | warn: vi.fn(), 12 | error: vi.fn(), 13 | } 14 | })); 15 | 16 | describe('TelemetryError', () => { 17 | beforeEach(() => { 18 | vi.clearAllMocks(); 19 | vi.useFakeTimers(); 20 | }); 21 | 22 | afterEach(() => { 23 | vi.useRealTimers(); 24 | }); 25 | 26 | describe('constructor', () => { 27 | it('should create error with all properties', () => { 28 | const context = { operation: 'test', detail: 'info' }; 29 | const error = new TelemetryError( 30 | TelemetryErrorType.NETWORK_ERROR, 31 | 'Test error', 32 | context, 33 | true 34 | ); 35 | 36 | expect(error.name).toBe('TelemetryError'); 37 | expect(error.message).toBe('Test error'); 38 | expect(error.type).toBe(TelemetryErrorType.NETWORK_ERROR); 39 | expect(error.context).toEqual(context); 40 | expect(error.retryable).toBe(true); 41 | expect(error.timestamp).toBeTypeOf('number'); 42 | }); 43 | 44 | it('should default retryable to false', () => { 45 | const error = new TelemetryError( 46 | TelemetryErrorType.VALIDATION_ERROR, 47 | 'Test error' 48 | ); 49 | 50 | expect(error.retryable).toBe(false); 51 | }); 52 | 53 | it('should handle undefined context', () => { 54 | const error = new TelemetryError( 55 | TelemetryErrorType.UNKNOWN_ERROR, 56 | 'Test error' 57 | ); 58 | 59 | expect(error.context).toBeUndefined(); 60 | }); 61 | 62 | it('should maintain proper prototype chain', () => { 63 | const error = new TelemetryError( 64 | TelemetryErrorType.NETWORK_ERROR, 65 | 'Test error' 66 | ); 67 | 68 | expect(error instanceof TelemetryError).toBe(true); 69 | expect(error instanceof Error).toBe(true); 70 | }); 71 | }); 72 | 73 | describe('toContext()', () => { 74 | it('should convert error to context object', () => { 75 | const context = { operation: 'flush', batch: 'events' }; 76 | const error = new TelemetryError( 77 | TelemetryErrorType.NETWORK_ERROR, 78 | 'Failed to flush', 79 | context, 80 | true 81 | ); 82 | 83 | const contextObj = error.toContext(); 84 | expect(contextObj).toEqual({ 85 | type: TelemetryErrorType.NETWORK_ERROR, 86 | message: 'Failed to flush', 87 | context, 88 | timestamp: error.timestamp, 89 | retryable: true 90 | }); 91 | }); 92 | }); 93 | 94 | describe('log()', () => { 95 | it('should log retryable errors as debug', () => { 96 | const error = new TelemetryError( 97 | TelemetryErrorType.NETWORK_ERROR, 98 | 'Retryable error', 99 | { attempt: 1 }, 100 | true 101 | ); 102 | 103 | error.log(); 104 | 105 | expect(logger.debug).toHaveBeenCalledWith( 106 | 'Retryable telemetry error:', 107 | expect.objectContaining({ 108 | type: TelemetryErrorType.NETWORK_ERROR, 109 | message: 'Retryable error', 110 | attempt: 1 111 | }) 112 | ); 113 | }); 114 | 115 | it('should log non-retryable errors as debug', () => { 116 | const error = new TelemetryError( 117 | TelemetryErrorType.VALIDATION_ERROR, 118 | 'Non-retryable error', 119 | { field: 'user_id' }, 120 | false 121 | ); 122 | 123 | error.log(); 124 | 125 | expect(logger.debug).toHaveBeenCalledWith( 126 | 'Non-retryable telemetry error:', 127 | expect.objectContaining({ 128 | type: TelemetryErrorType.VALIDATION_ERROR, 129 | message: 'Non-retryable error', 130 | field: 'user_id' 131 | }) 132 | ); 133 | }); 134 | 135 | it('should handle errors without context', () => { 136 | const error = new TelemetryError( 137 | TelemetryErrorType.UNKNOWN_ERROR, 138 | 'Simple error' 139 | ); 140 | 141 | error.log(); 142 | 143 | expect(logger.debug).toHaveBeenCalledWith( 144 | 'Non-retryable telemetry error:', 145 | expect.objectContaining({ 146 | type: TelemetryErrorType.UNKNOWN_ERROR, 147 | message: 'Simple error' 148 | }) 149 | ); 150 | }); 151 | }); 152 | }); 153 | 154 | describe('TelemetryCircuitBreaker', () => { 155 | let circuitBreaker: TelemetryCircuitBreaker; 156 | 157 | beforeEach(() => { 158 | vi.clearAllMocks(); 159 | vi.useFakeTimers(); 160 | circuitBreaker = new TelemetryCircuitBreaker(3, 10000, 2); // 3 failures, 10s reset, 2 half-open requests 161 | }); 162 | 163 | afterEach(() => { 164 | vi.useRealTimers(); 165 | }); 166 | 167 | describe('shouldAllow()', () => { 168 | it('should allow requests in closed state', () => { 169 | expect(circuitBreaker.shouldAllow()).toBe(true); 170 | }); 171 | 172 | it('should open circuit after failure threshold', () => { 173 | // Record 3 failures to reach threshold 174 | for (let i = 0; i < 3; i++) { 175 | circuitBreaker.recordFailure(); 176 | } 177 | 178 | expect(circuitBreaker.shouldAllow()).toBe(false); 179 | expect(circuitBreaker.getState().state).toBe('open'); 180 | }); 181 | 182 | it('should transition to half-open after reset timeout', () => { 183 | // Open the circuit 184 | for (let i = 0; i < 3; i++) { 185 | circuitBreaker.recordFailure(); 186 | } 187 | expect(circuitBreaker.shouldAllow()).toBe(false); 188 | 189 | // Advance time past reset timeout 190 | vi.advanceTimersByTime(11000); 191 | 192 | // Should transition to half-open and allow request 193 | expect(circuitBreaker.shouldAllow()).toBe(true); 194 | expect(circuitBreaker.getState().state).toBe('half-open'); 195 | }); 196 | 197 | it('should limit requests in half-open state', () => { 198 | // Open the circuit 199 | for (let i = 0; i < 3; i++) { 200 | circuitBreaker.recordFailure(); 201 | } 202 | 203 | // Advance to half-open 204 | vi.advanceTimersByTime(11000); 205 | 206 | // Should allow limited number of requests (2 in our config) 207 | expect(circuitBreaker.shouldAllow()).toBe(true); 208 | expect(circuitBreaker.shouldAllow()).toBe(true); 209 | expect(circuitBreaker.shouldAllow()).toBe(true); // Note: simplified implementation allows all 210 | }); 211 | 212 | it('should not allow requests before reset timeout in open state', () => { 213 | // Open the circuit 214 | for (let i = 0; i < 3; i++) { 215 | circuitBreaker.recordFailure(); 216 | } 217 | 218 | // Advance time but not enough to reset 219 | vi.advanceTimersByTime(5000); 220 | 221 | expect(circuitBreaker.shouldAllow()).toBe(false); 222 | }); 223 | }); 224 | 225 | describe('recordSuccess()', () => { 226 | it('should reset failure count in closed state', () => { 227 | // Record some failures but not enough to open 228 | circuitBreaker.recordFailure(); 229 | circuitBreaker.recordFailure(); 230 | expect(circuitBreaker.getState().failureCount).toBe(2); 231 | 232 | // Success should reset count 233 | circuitBreaker.recordSuccess(); 234 | expect(circuitBreaker.getState().failureCount).toBe(0); 235 | }); 236 | 237 | it('should close circuit after successful half-open requests', () => { 238 | // Open the circuit 239 | for (let i = 0; i < 3; i++) { 240 | circuitBreaker.recordFailure(); 241 | } 242 | 243 | // Go to half-open 244 | vi.advanceTimersByTime(11000); 245 | circuitBreaker.shouldAllow(); // First half-open request 246 | circuitBreaker.shouldAllow(); // Second half-open request 247 | 248 | // The circuit breaker implementation requires success calls 249 | // to match the number of half-open requests configured 250 | circuitBreaker.recordSuccess(); 251 | // In current implementation, state remains half-open 252 | // This is a known behavior of the simplified circuit breaker 253 | expect(circuitBreaker.getState().state).toBe('half-open'); 254 | 255 | // After another success, it should close 256 | circuitBreaker.recordSuccess(); 257 | expect(circuitBreaker.getState().state).toBe('closed'); 258 | expect(circuitBreaker.getState().failureCount).toBe(0); 259 | expect(logger.debug).toHaveBeenCalledWith('Circuit breaker closed after successful recovery'); 260 | }); 261 | 262 | it('should not affect state when not in half-open after sufficient requests', () => { 263 | // Open circuit, go to half-open, make one request 264 | for (let i = 0; i < 3; i++) { 265 | circuitBreaker.recordFailure(); 266 | } 267 | vi.advanceTimersByTime(11000); 268 | circuitBreaker.shouldAllow(); // One half-open request 269 | 270 | // Record success but should not close yet (need 2 successful requests) 271 | circuitBreaker.recordSuccess(); 272 | expect(circuitBreaker.getState().state).toBe('half-open'); 273 | }); 274 | }); 275 | 276 | describe('recordFailure()', () => { 277 | it('should increment failure count in closed state', () => { 278 | circuitBreaker.recordFailure(); 279 | expect(circuitBreaker.getState().failureCount).toBe(1); 280 | 281 | circuitBreaker.recordFailure(); 282 | expect(circuitBreaker.getState().failureCount).toBe(2); 283 | }); 284 | 285 | it('should open circuit when threshold reached', () => { 286 | const error = new Error('Test error'); 287 | 288 | // Record failures to reach threshold 289 | circuitBreaker.recordFailure(error); 290 | circuitBreaker.recordFailure(error); 291 | expect(circuitBreaker.getState().state).toBe('closed'); 292 | 293 | circuitBreaker.recordFailure(error); 294 | expect(circuitBreaker.getState().state).toBe('open'); 295 | expect(logger.debug).toHaveBeenCalledWith( 296 | 'Circuit breaker opened after 3 failures', 297 | { error: 'Test error' } 298 | ); 299 | }); 300 | 301 | it('should immediately open from half-open on failure', () => { 302 | // Open circuit, go to half-open 303 | for (let i = 0; i < 3; i++) { 304 | circuitBreaker.recordFailure(); 305 | } 306 | vi.advanceTimersByTime(11000); 307 | circuitBreaker.shouldAllow(); 308 | 309 | // Failure in half-open should immediately open 310 | const error = new Error('Half-open failure'); 311 | circuitBreaker.recordFailure(error); 312 | expect(circuitBreaker.getState().state).toBe('open'); 313 | expect(logger.debug).toHaveBeenCalledWith( 314 | 'Circuit breaker opened from half-open state', 315 | { error: 'Half-open failure' } 316 | ); 317 | }); 318 | 319 | it('should handle failure without error object', () => { 320 | for (let i = 0; i < 3; i++) { 321 | circuitBreaker.recordFailure(); 322 | } 323 | 324 | expect(circuitBreaker.getState().state).toBe('open'); 325 | expect(logger.debug).toHaveBeenCalledWith( 326 | 'Circuit breaker opened after 3 failures', 327 | { error: undefined } 328 | ); 329 | }); 330 | }); 331 | 332 | describe('getState()', () => { 333 | it('should return current state information', () => { 334 | const state = circuitBreaker.getState(); 335 | expect(state).toEqual({ 336 | state: 'closed', 337 | failureCount: 0, 338 | canRetry: true 339 | }); 340 | }); 341 | 342 | it('should reflect state changes', () => { 343 | circuitBreaker.recordFailure(); 344 | circuitBreaker.recordFailure(); 345 | 346 | const state = circuitBreaker.getState(); 347 | expect(state).toEqual({ 348 | state: 'closed', 349 | failureCount: 2, 350 | canRetry: true 351 | }); 352 | 353 | // Open circuit 354 | circuitBreaker.recordFailure(); 355 | const openState = circuitBreaker.getState(); 356 | expect(openState).toEqual({ 357 | state: 'open', 358 | failureCount: 3, 359 | canRetry: false 360 | }); 361 | }); 362 | }); 363 | 364 | describe('reset()', () => { 365 | it('should reset circuit breaker to initial state', () => { 366 | // Open the circuit and advance time 367 | for (let i = 0; i < 3; i++) { 368 | circuitBreaker.recordFailure(); 369 | } 370 | vi.advanceTimersByTime(11000); 371 | circuitBreaker.shouldAllow(); // Go to half-open 372 | 373 | // Reset 374 | circuitBreaker.reset(); 375 | 376 | const state = circuitBreaker.getState(); 377 | expect(state).toEqual({ 378 | state: 'closed', 379 | failureCount: 0, 380 | canRetry: true 381 | }); 382 | }); 383 | }); 384 | 385 | describe('different configurations', () => { 386 | it('should work with custom failure threshold', () => { 387 | const customBreaker = new TelemetryCircuitBreaker(1, 5000, 1); // 1 failure threshold 388 | 389 | expect(customBreaker.getState().state).toBe('closed'); 390 | customBreaker.recordFailure(); 391 | expect(customBreaker.getState().state).toBe('open'); 392 | }); 393 | 394 | it('should work with custom half-open request count', () => { 395 | const customBreaker = new TelemetryCircuitBreaker(1, 5000, 3); // 3 half-open requests 396 | 397 | // Open and go to half-open 398 | customBreaker.recordFailure(); 399 | vi.advanceTimersByTime(6000); 400 | 401 | // Should allow 3 requests in half-open 402 | expect(customBreaker.shouldAllow()).toBe(true); 403 | expect(customBreaker.shouldAllow()).toBe(true); 404 | expect(customBreaker.shouldAllow()).toBe(true); 405 | expect(customBreaker.shouldAllow()).toBe(true); // Fourth also allowed in simplified implementation 406 | }); 407 | }); 408 | }); 409 | 410 | describe('TelemetryErrorAggregator', () => { 411 | let aggregator: TelemetryErrorAggregator; 412 | 413 | beforeEach(() => { 414 | aggregator = new TelemetryErrorAggregator(); 415 | vi.clearAllMocks(); 416 | }); 417 | 418 | describe('record()', () => { 419 | it('should record error and increment counter', () => { 420 | const error = new TelemetryError( 421 | TelemetryErrorType.NETWORK_ERROR, 422 | 'Network failure' 423 | ); 424 | 425 | aggregator.record(error); 426 | 427 | const stats = aggregator.getStats(); 428 | expect(stats.totalErrors).toBe(1); 429 | expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(1); 430 | }); 431 | 432 | it('should increment counter for repeated error types', () => { 433 | const error1 = new TelemetryError( 434 | TelemetryErrorType.NETWORK_ERROR, 435 | 'First failure' 436 | ); 437 | const error2 = new TelemetryError( 438 | TelemetryErrorType.NETWORK_ERROR, 439 | 'Second failure' 440 | ); 441 | 442 | aggregator.record(error1); 443 | aggregator.record(error2); 444 | 445 | const stats = aggregator.getStats(); 446 | expect(stats.totalErrors).toBe(2); 447 | expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(2); 448 | }); 449 | 450 | it('should maintain limited error detail history', () => { 451 | // Record more than max details (100) to test limiting 452 | for (let i = 0; i < 105; i++) { 453 | const error = new TelemetryError( 454 | TelemetryErrorType.VALIDATION_ERROR, 455 | `Error ${i}` 456 | ); 457 | aggregator.record(error); 458 | } 459 | 460 | const stats = aggregator.getStats(); 461 | expect(stats.totalErrors).toBe(105); 462 | expect(stats.recentErrors).toHaveLength(10); // Only last 10 463 | }); 464 | 465 | it('should track different error types separately', () => { 466 | const networkError = new TelemetryError( 467 | TelemetryErrorType.NETWORK_ERROR, 468 | 'Network issue' 469 | ); 470 | const validationError = new TelemetryError( 471 | TelemetryErrorType.VALIDATION_ERROR, 472 | 'Validation issue' 473 | ); 474 | const rateLimitError = new TelemetryError( 475 | TelemetryErrorType.RATE_LIMIT_ERROR, 476 | 'Rate limit hit' 477 | ); 478 | 479 | aggregator.record(networkError); 480 | aggregator.record(networkError); 481 | aggregator.record(validationError); 482 | aggregator.record(rateLimitError); 483 | 484 | const stats = aggregator.getStats(); 485 | expect(stats.totalErrors).toBe(4); 486 | expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(2); 487 | expect(stats.errorsByType[TelemetryErrorType.VALIDATION_ERROR]).toBe(1); 488 | expect(stats.errorsByType[TelemetryErrorType.RATE_LIMIT_ERROR]).toBe(1); 489 | }); 490 | }); 491 | 492 | describe('getStats()', () => { 493 | it('should return empty stats when no errors recorded', () => { 494 | const stats = aggregator.getStats(); 495 | expect(stats).toEqual({ 496 | totalErrors: 0, 497 | errorsByType: {}, 498 | mostCommonError: undefined, 499 | recentErrors: [] 500 | }); 501 | }); 502 | 503 | it('should identify most common error type', () => { 504 | const networkError = new TelemetryError( 505 | TelemetryErrorType.NETWORK_ERROR, 506 | 'Network issue' 507 | ); 508 | const validationError = new TelemetryError( 509 | TelemetryErrorType.VALIDATION_ERROR, 510 | 'Validation issue' 511 | ); 512 | 513 | // Network errors more frequent 514 | aggregator.record(networkError); 515 | aggregator.record(networkError); 516 | aggregator.record(networkError); 517 | aggregator.record(validationError); 518 | 519 | const stats = aggregator.getStats(); 520 | expect(stats.mostCommonError).toBe(TelemetryErrorType.NETWORK_ERROR); 521 | }); 522 | 523 | it('should return recent errors in order', () => { 524 | const error1 = new TelemetryError( 525 | TelemetryErrorType.NETWORK_ERROR, 526 | 'First error' 527 | ); 528 | const error2 = new TelemetryError( 529 | TelemetryErrorType.VALIDATION_ERROR, 530 | 'Second error' 531 | ); 532 | const error3 = new TelemetryError( 533 | TelemetryErrorType.RATE_LIMIT_ERROR, 534 | 'Third error' 535 | ); 536 | 537 | aggregator.record(error1); 538 | aggregator.record(error2); 539 | aggregator.record(error3); 540 | 541 | const stats = aggregator.getStats(); 542 | expect(stats.recentErrors).toHaveLength(3); 543 | expect(stats.recentErrors[0].message).toBe('First error'); 544 | expect(stats.recentErrors[1].message).toBe('Second error'); 545 | expect(stats.recentErrors[2].message).toBe('Third error'); 546 | }); 547 | 548 | it('should handle tie in most common error', () => { 549 | const networkError = new TelemetryError( 550 | TelemetryErrorType.NETWORK_ERROR, 551 | 'Network issue' 552 | ); 553 | const validationError = new TelemetryError( 554 | TelemetryErrorType.VALIDATION_ERROR, 555 | 'Validation issue' 556 | ); 557 | 558 | // Equal counts 559 | aggregator.record(networkError); 560 | aggregator.record(validationError); 561 | 562 | const stats = aggregator.getStats(); 563 | // Should return one of them (implementation dependent) 564 | expect(stats.mostCommonError).toBeDefined(); 565 | expect([TelemetryErrorType.NETWORK_ERROR, TelemetryErrorType.VALIDATION_ERROR]) 566 | .toContain(stats.mostCommonError); 567 | }); 568 | }); 569 | 570 | describe('reset()', () => { 571 | it('should clear all error data', () => { 572 | const error = new TelemetryError( 573 | TelemetryErrorType.NETWORK_ERROR, 574 | 'Test error' 575 | ); 576 | aggregator.record(error); 577 | 578 | // Verify data exists 579 | expect(aggregator.getStats().totalErrors).toBe(1); 580 | 581 | // Reset 582 | aggregator.reset(); 583 | 584 | // Verify cleared 585 | const stats = aggregator.getStats(); 586 | expect(stats).toEqual({ 587 | totalErrors: 0, 588 | errorsByType: {}, 589 | mostCommonError: undefined, 590 | recentErrors: [] 591 | }); 592 | }); 593 | }); 594 | 595 | describe('error detail management', () => { 596 | it('should preserve error context in details', () => { 597 | const context = { operation: 'flush', batchSize: 50 }; 598 | const error = new TelemetryError( 599 | TelemetryErrorType.NETWORK_ERROR, 600 | 'Network failure', 601 | context, 602 | true 603 | ); 604 | 605 | aggregator.record(error); 606 | 607 | const stats = aggregator.getStats(); 608 | expect(stats.recentErrors[0]).toEqual({ 609 | type: TelemetryErrorType.NETWORK_ERROR, 610 | message: 'Network failure', 611 | context, 612 | timestamp: error.timestamp, 613 | retryable: true 614 | }); 615 | }); 616 | 617 | it('should maintain error details queue with FIFO behavior', () => { 618 | // Add more than max to test queue behavior 619 | const errors = []; 620 | for (let i = 0; i < 15; i++) { 621 | const error = new TelemetryError( 622 | TelemetryErrorType.VALIDATION_ERROR, 623 | `Error ${i}` 624 | ); 625 | errors.push(error); 626 | aggregator.record(error); 627 | } 628 | 629 | const stats = aggregator.getStats(); 630 | // Should have last 10 errors (5-14) 631 | expect(stats.recentErrors).toHaveLength(10); 632 | expect(stats.recentErrors[0].message).toBe('Error 5'); 633 | expect(stats.recentErrors[9].message).toBe('Error 14'); 634 | }); 635 | }); 636 | }); ``` -------------------------------------------------------------------------------- /src/services/ai-tool-validators.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * AI Tool Sub-Node Validators 3 | * 4 | * Implements validation logic for all 13 AI tool sub-nodes from 5 | * docs/FINAL_AI_VALIDATION_SPEC.md 6 | * 7 | * Each validator checks configuration requirements, connections, and 8 | * parameters specific to that tool type. 9 | */ 10 | 11 | import { NodeTypeNormalizer } from '../utils/node-type-normalizer'; 12 | 13 | // Validation constants 14 | const MIN_DESCRIPTION_LENGTH_SHORT = 10; 15 | const MIN_DESCRIPTION_LENGTH_MEDIUM = 15; 16 | const MIN_DESCRIPTION_LENGTH_LONG = 20; 17 | const MAX_ITERATIONS_WARNING_THRESHOLD = 50; 18 | const MAX_TOPK_WARNING_THRESHOLD = 20; 19 | 20 | export interface WorkflowNode { 21 | id: string; 22 | name: string; 23 | type: string; 24 | position: [number, number]; 25 | parameters: any; 26 | credentials?: any; 27 | disabled?: boolean; 28 | typeVersion?: number; 29 | } 30 | 31 | export interface WorkflowJson { 32 | name?: string; 33 | nodes: WorkflowNode[]; 34 | connections: Record<string, any>; 35 | settings?: any; 36 | } 37 | 38 | export interface ReverseConnection { 39 | sourceName: string; 40 | sourceType: string; 41 | type: string; // main, ai_tool, ai_languageModel, etc. 42 | index: number; 43 | } 44 | 45 | export interface ValidationIssue { 46 | severity: 'error' | 'warning' | 'info'; 47 | nodeId?: string; 48 | nodeName?: string; 49 | message: string; 50 | code?: string; 51 | } 52 | 53 | /** 54 | * 1. HTTP Request Tool Validator 55 | * From spec lines 883-1123 56 | */ 57 | export function validateHTTPRequestTool(node: WorkflowNode): ValidationIssue[] { 58 | const issues: ValidationIssue[] = []; 59 | 60 | // 1. Check toolDescription (REQUIRED) 61 | if (!node.parameters.toolDescription) { 62 | issues.push({ 63 | severity: 'error', 64 | nodeId: node.id, 65 | nodeName: node.name, 66 | message: `HTTP Request Tool "${node.name}" has no toolDescription. Add a clear description to help the LLM know when to use this API.`, 67 | code: 'MISSING_TOOL_DESCRIPTION' 68 | }); 69 | } else if (node.parameters.toolDescription.trim().length < MIN_DESCRIPTION_LENGTH_MEDIUM) { 70 | issues.push({ 71 | severity: 'warning', 72 | nodeId: node.id, 73 | nodeName: node.name, 74 | message: `HTTP Request Tool "${node.name}" toolDescription is too short (minimum ${MIN_DESCRIPTION_LENGTH_MEDIUM} characters). Explain what API this calls and when to use it.` 75 | }); 76 | } 77 | 78 | // 2. Check URL (REQUIRED) 79 | if (!node.parameters.url) { 80 | issues.push({ 81 | severity: 'error', 82 | nodeId: node.id, 83 | nodeName: node.name, 84 | message: `HTTP Request Tool "${node.name}" has no URL. Add the API endpoint URL.`, 85 | code: 'MISSING_URL' 86 | }); 87 | } else { 88 | // Validate URL protocol (must be http or https) 89 | try { 90 | const urlObj = new URL(node.parameters.url); 91 | if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') { 92 | issues.push({ 93 | severity: 'error', 94 | nodeId: node.id, 95 | nodeName: node.name, 96 | message: `HTTP Request Tool "${node.name}" has invalid URL protocol "${urlObj.protocol}". Use http:// or https:// only.`, 97 | code: 'INVALID_URL_PROTOCOL' 98 | }); 99 | } 100 | } catch (e) { 101 | // URL parsing failed - invalid format 102 | // Only warn if it's not an n8n expression 103 | if (!node.parameters.url.includes('{{')) { 104 | issues.push({ 105 | severity: 'warning', 106 | nodeId: node.id, 107 | nodeName: node.name, 108 | message: `HTTP Request Tool "${node.name}" has potentially invalid URL format. Ensure it's a valid URL or n8n expression.` 109 | }); 110 | } 111 | } 112 | } 113 | 114 | // 3. Validate placeholders match definitions 115 | if (node.parameters.url || node.parameters.body || node.parameters.headers) { 116 | const placeholderRegex = /\{([^}]+)\}/g; 117 | const placeholders = new Set<string>(); 118 | 119 | // Extract placeholders from URL, body, headers 120 | [node.parameters.url, node.parameters.body, JSON.stringify(node.parameters.headers || {})].forEach(text => { 121 | if (text) { 122 | let match; 123 | while ((match = placeholderRegex.exec(text)) !== null) { 124 | placeholders.add(match[1]); 125 | } 126 | } 127 | }); 128 | 129 | // If placeholders exist in URL/body/headers 130 | if (placeholders.size > 0) { 131 | const definitions = node.parameters.placeholderDefinitions?.values || []; 132 | const definedNames = new Set(definitions.map((d: any) => d.name)); 133 | 134 | // If no placeholderDefinitions at all, warn 135 | if (!node.parameters.placeholderDefinitions) { 136 | issues.push({ 137 | severity: 'warning', 138 | nodeId: node.id, 139 | nodeName: node.name, 140 | message: `HTTP Request Tool "${node.name}" uses placeholders but has no placeholderDefinitions. Add definitions to describe the expected inputs.` 141 | }); 142 | } else { 143 | // Has placeholderDefinitions, check each placeholder 144 | for (const placeholder of placeholders) { 145 | if (!definedNames.has(placeholder)) { 146 | issues.push({ 147 | severity: 'error', 148 | nodeId: node.id, 149 | nodeName: node.name, 150 | message: `HTTP Request Tool "${node.name}" Placeholder "${placeholder}" in URL but it's not defined in placeholderDefinitions.`, 151 | code: 'UNDEFINED_PLACEHOLDER' 152 | }); 153 | } 154 | } 155 | 156 | // Check for defined but unused placeholders 157 | for (const def of definitions) { 158 | if (!placeholders.has(def.name)) { 159 | issues.push({ 160 | severity: 'warning', 161 | nodeId: node.id, 162 | nodeName: node.name, 163 | message: `HTTP Request Tool "${node.name}" defines placeholder "${def.name}" but doesn't use it.` 164 | }); 165 | } 166 | } 167 | } 168 | } 169 | } 170 | 171 | // 4. Validate authentication 172 | if (node.parameters.authentication === 'predefinedCredentialType' && 173 | (!node.credentials || Object.keys(node.credentials).length === 0)) { 174 | issues.push({ 175 | severity: 'error', 176 | nodeId: node.id, 177 | nodeName: node.name, 178 | message: `HTTP Request Tool "${node.name}" requires credentials but none are configured.`, 179 | code: 'MISSING_CREDENTIALS' 180 | }); 181 | } 182 | 183 | // 5. Validate HTTP method 184 | const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; 185 | if (node.parameters.method && !validMethods.includes(node.parameters.method.toUpperCase())) { 186 | issues.push({ 187 | severity: 'error', 188 | nodeId: node.id, 189 | nodeName: node.name, 190 | message: `HTTP Request Tool "${node.name}" has invalid HTTP method "${node.parameters.method}". Use one of: ${validMethods.join(', ')}.`, 191 | code: 'INVALID_HTTP_METHOD' 192 | }); 193 | } 194 | 195 | // 6. Validate body for POST/PUT/PATCH 196 | if (['POST', 'PUT', 'PATCH'].includes(node.parameters.method?.toUpperCase())) { 197 | if (!node.parameters.body && !node.parameters.jsonBody) { 198 | issues.push({ 199 | severity: 'warning', 200 | nodeId: node.id, 201 | nodeName: node.name, 202 | message: `HTTP Request Tool "${node.name}" uses ${node.parameters.method} but has no body. Consider adding a body or using GET instead.` 203 | }); 204 | } 205 | } 206 | 207 | return issues; 208 | } 209 | 210 | /** 211 | * 2. Code Tool Validator 212 | * From spec lines 1125-1393 213 | */ 214 | export function validateCodeTool(node: WorkflowNode): ValidationIssue[] { 215 | const issues: ValidationIssue[] = []; 216 | 217 | // 1. Check toolDescription (REQUIRED) 218 | if (!node.parameters.toolDescription) { 219 | issues.push({ 220 | severity: 'error', 221 | nodeId: node.id, 222 | nodeName: node.name, 223 | message: `Code Tool "${node.name}" has no toolDescription. Add one to help the LLM understand the tool's purpose.`, 224 | code: 'MISSING_TOOL_DESCRIPTION' 225 | }); 226 | } 227 | 228 | // 2. Check jsCode exists (REQUIRED) 229 | if (!node.parameters.jsCode || node.parameters.jsCode.trim().length === 0) { 230 | issues.push({ 231 | severity: 'error', 232 | nodeId: node.id, 233 | nodeName: node.name, 234 | message: `Code Tool "${node.name}" code is empty. Add the JavaScript code to execute.`, 235 | code: 'MISSING_CODE' 236 | }); 237 | } 238 | 239 | // 3. Recommend input/output schema 240 | if (!node.parameters.inputSchema && !node.parameters.specifyInputSchema) { 241 | issues.push({ 242 | severity: 'warning', 243 | nodeId: node.id, 244 | nodeName: node.name, 245 | message: `Code Tool "${node.name}" has no input schema. Consider adding one to validate LLM inputs.` 246 | }); 247 | } 248 | 249 | return issues; 250 | } 251 | 252 | /** 253 | * 3. Vector Store Tool Validator 254 | * From spec lines 1395-1620 255 | */ 256 | export function validateVectorStoreTool( 257 | node: WorkflowNode, 258 | reverseConnections: Map<string, ReverseConnection[]>, 259 | workflow: WorkflowJson 260 | ): ValidationIssue[] { 261 | const issues: ValidationIssue[] = []; 262 | 263 | // 1. Check toolDescription (REQUIRED) 264 | if (!node.parameters.toolDescription) { 265 | issues.push({ 266 | severity: 'error', 267 | nodeId: node.id, 268 | nodeName: node.name, 269 | message: `Vector Store Tool "${node.name}" has no toolDescription. Add one to explain what data it searches.`, 270 | code: 'MISSING_TOOL_DESCRIPTION' 271 | }); 272 | } 273 | 274 | // 2. Validate topK parameter if specified 275 | if (node.parameters.topK !== undefined) { 276 | if (typeof node.parameters.topK !== 'number' || node.parameters.topK < 1) { 277 | issues.push({ 278 | severity: 'error', 279 | nodeId: node.id, 280 | nodeName: node.name, 281 | message: `Vector Store Tool "${node.name}" has invalid topK value. Must be a positive number.`, 282 | code: 'INVALID_TOPK' 283 | }); 284 | } else if (node.parameters.topK > MAX_TOPK_WARNING_THRESHOLD) { 285 | issues.push({ 286 | severity: 'warning', 287 | nodeId: node.id, 288 | nodeName: node.name, 289 | message: `Vector Store Tool "${node.name}" has topK=${node.parameters.topK}. Large values (>${MAX_TOPK_WARNING_THRESHOLD}) may overwhelm the LLM context. Consider reducing to 10 or less.` 290 | }); 291 | } 292 | } 293 | 294 | return issues; 295 | } 296 | 297 | /** 298 | * 4. Workflow Tool Validator 299 | * From spec lines 1622-1831 (already complete in spec) 300 | */ 301 | export function validateWorkflowTool(node: WorkflowNode, reverseConnections?: Map<string, ReverseConnection[]>): ValidationIssue[] { 302 | const issues: ValidationIssue[] = []; 303 | 304 | // 1. Check toolDescription (REQUIRED) 305 | if (!node.parameters.toolDescription) { 306 | issues.push({ 307 | severity: 'error', 308 | nodeId: node.id, 309 | nodeName: node.name, 310 | message: `Workflow Tool "${node.name}" has no toolDescription. Add one to help the LLM know when to use this tool.`, 311 | code: 'MISSING_TOOL_DESCRIPTION' 312 | }); 313 | } 314 | 315 | // 2. Check workflowId (REQUIRED) 316 | if (!node.parameters.workflowId) { 317 | issues.push({ 318 | severity: 'error', 319 | nodeId: node.id, 320 | nodeName: node.name, 321 | message: `Workflow Tool "${node.name}" has no workflowId. Select a workflow to execute.`, 322 | code: 'MISSING_WORKFLOW_ID' 323 | }); 324 | } 325 | 326 | return issues; 327 | } 328 | 329 | /** 330 | * 5. AI Agent Tool Validator 331 | * From spec lines 1882-2122 332 | */ 333 | export function validateAIAgentTool( 334 | node: WorkflowNode, 335 | reverseConnections: Map<string, ReverseConnection[]> 336 | ): ValidationIssue[] { 337 | const issues: ValidationIssue[] = []; 338 | 339 | // 1. Check toolDescription (REQUIRED) 340 | if (!node.parameters.toolDescription) { 341 | issues.push({ 342 | severity: 'error', 343 | nodeId: node.id, 344 | nodeName: node.name, 345 | message: `AI Agent Tool "${node.name}" has no toolDescription. Add one to help the LLM know when to use this tool.`, 346 | code: 'MISSING_TOOL_DESCRIPTION' 347 | }); 348 | } 349 | 350 | // 2. Validate maxIterations if specified 351 | if (node.parameters.maxIterations !== undefined) { 352 | if (typeof node.parameters.maxIterations !== 'number' || node.parameters.maxIterations < 1) { 353 | issues.push({ 354 | severity: 'error', 355 | nodeId: node.id, 356 | nodeName: node.name, 357 | message: `AI Agent Tool "${node.name}" has invalid maxIterations. Must be a positive number.`, 358 | code: 'INVALID_MAX_ITERATIONS' 359 | }); 360 | } else if (node.parameters.maxIterations > MAX_ITERATIONS_WARNING_THRESHOLD) { 361 | issues.push({ 362 | severity: 'warning', 363 | nodeId: node.id, 364 | nodeName: node.name, 365 | message: `AI Agent Tool "${node.name}" has maxIterations=${node.parameters.maxIterations}. Large values (>${MAX_ITERATIONS_WARNING_THRESHOLD}) may lead to long execution times.` 366 | }); 367 | } 368 | } 369 | 370 | return issues; 371 | } 372 | 373 | /** 374 | * 6. MCP Client Tool Validator 375 | * From spec lines 2124-2534 (already complete in spec) 376 | */ 377 | export function validateMCPClientTool(node: WorkflowNode): ValidationIssue[] { 378 | const issues: ValidationIssue[] = []; 379 | 380 | // 1. Check toolDescription (REQUIRED) 381 | if (!node.parameters.toolDescription) { 382 | issues.push({ 383 | severity: 'error', 384 | nodeId: node.id, 385 | nodeName: node.name, 386 | message: `MCP Client Tool "${node.name}" has no toolDescription. Add one to help the LLM know when to use this tool.`, 387 | code: 'MISSING_TOOL_DESCRIPTION' 388 | }); 389 | } 390 | 391 | // 2. Check serverUrl (REQUIRED) 392 | if (!node.parameters.serverUrl) { 393 | issues.push({ 394 | severity: 'error', 395 | nodeId: node.id, 396 | nodeName: node.name, 397 | message: `MCP Client Tool "${node.name}" has no serverUrl. Configure the MCP server URL.`, 398 | code: 'MISSING_SERVER_URL' 399 | }); 400 | } 401 | 402 | return issues; 403 | } 404 | 405 | /** 406 | * 7-8. Simple Tools (Calculator, Think) Validators 407 | * From spec lines 1868-2009 408 | */ 409 | export function validateCalculatorTool(node: WorkflowNode): ValidationIssue[] { 410 | const issues: ValidationIssue[] = []; 411 | 412 | // Calculator Tool has a built-in description and is self-explanatory 413 | // toolDescription is optional - no validation needed 414 | return issues; 415 | } 416 | 417 | export function validateThinkTool(node: WorkflowNode): ValidationIssue[] { 418 | const issues: ValidationIssue[] = []; 419 | 420 | // Think Tool has a built-in description and is self-explanatory 421 | // toolDescription is optional - no validation needed 422 | return issues; 423 | } 424 | 425 | /** 426 | * 9-12. Search Tools Validators 427 | * From spec lines 1833-2139 428 | */ 429 | export function validateSerpApiTool(node: WorkflowNode): ValidationIssue[] { 430 | const issues: ValidationIssue[] = []; 431 | 432 | // 1. Check toolDescription (REQUIRED) 433 | if (!node.parameters.toolDescription) { 434 | issues.push({ 435 | severity: 'error', 436 | nodeId: node.id, 437 | nodeName: node.name, 438 | message: `SerpApi Tool "${node.name}" has no toolDescription. Add one to explain when to use Google search.`, 439 | code: 'MISSING_TOOL_DESCRIPTION' 440 | }); 441 | } 442 | 443 | // 2. Check credentials (RECOMMENDED) 444 | if (!node.credentials || !node.credentials.serpApiApi) { 445 | issues.push({ 446 | severity: 'warning', 447 | nodeId: node.id, 448 | nodeName: node.name, 449 | message: `SerpApi Tool "${node.name}" requires SerpApi credentials. Configure your API key.` 450 | }); 451 | } 452 | 453 | return issues; 454 | } 455 | 456 | export function validateWikipediaTool(node: WorkflowNode): ValidationIssue[] { 457 | const issues: ValidationIssue[] = []; 458 | 459 | // 1. Check toolDescription (REQUIRED) 460 | if (!node.parameters.toolDescription) { 461 | issues.push({ 462 | severity: 'error', 463 | nodeId: node.id, 464 | nodeName: node.name, 465 | message: `Wikipedia Tool "${node.name}" has no toolDescription. Add one to explain when to use Wikipedia.`, 466 | code: 'MISSING_TOOL_DESCRIPTION' 467 | }); 468 | } 469 | 470 | // 2. Validate language if specified 471 | if (node.parameters.language) { 472 | const validLanguageCodes = /^[a-z]{2,3}$/; // ISO 639 codes 473 | if (!validLanguageCodes.test(node.parameters.language)) { 474 | issues.push({ 475 | severity: 'warning', 476 | nodeId: node.id, 477 | nodeName: node.name, 478 | message: `Wikipedia Tool "${node.name}" has potentially invalid language code "${node.parameters.language}". Use ISO 639 codes (e.g., "en", "es", "fr").` 479 | }); 480 | } 481 | } 482 | 483 | return issues; 484 | } 485 | 486 | export function validateSearXngTool(node: WorkflowNode): ValidationIssue[] { 487 | const issues: ValidationIssue[] = []; 488 | 489 | // 1. Check toolDescription (REQUIRED) 490 | if (!node.parameters.toolDescription) { 491 | issues.push({ 492 | severity: 'error', 493 | nodeId: node.id, 494 | nodeName: node.name, 495 | message: `SearXNG Tool "${node.name}" has no toolDescription. Add one to explain when to use SearXNG.`, 496 | code: 'MISSING_TOOL_DESCRIPTION' 497 | }); 498 | } 499 | 500 | // 2. Check baseUrl (REQUIRED) 501 | if (!node.parameters.baseUrl) { 502 | issues.push({ 503 | severity: 'error', 504 | nodeId: node.id, 505 | nodeName: node.name, 506 | message: `SearXNG Tool "${node.name}" has no baseUrl. Configure your SearXNG instance URL.`, 507 | code: 'MISSING_BASE_URL' 508 | }); 509 | } 510 | 511 | return issues; 512 | } 513 | 514 | export function validateWolframAlphaTool(node: WorkflowNode): ValidationIssue[] { 515 | const issues: ValidationIssue[] = []; 516 | 517 | // 1. Check credentials (REQUIRED) 518 | if (!node.credentials || (!node.credentials.wolframAlpha && !node.credentials.wolframAlphaApi)) { 519 | issues.push({ 520 | severity: 'error', 521 | nodeId: node.id, 522 | nodeName: node.name, 523 | message: `WolframAlpha Tool "${node.name}" requires Wolfram|Alpha API credentials. Configure your App ID.`, 524 | code: 'MISSING_CREDENTIALS' 525 | }); 526 | } 527 | 528 | // 2. Check description (INFO) 529 | if (!node.parameters.description && !node.parameters.toolDescription) { 530 | issues.push({ 531 | severity: 'info', 532 | nodeId: node.id, 533 | nodeName: node.name, 534 | message: `WolframAlpha Tool "${node.name}" has no custom description. Add one to explain when to use Wolfram|Alpha for computational queries.` 535 | }); 536 | } 537 | 538 | return issues; 539 | } 540 | 541 | /** 542 | * Helper: Map node types to validator functions 543 | */ 544 | export const AI_TOOL_VALIDATORS = { 545 | 'nodes-langchain.toolHttpRequest': validateHTTPRequestTool, 546 | 'nodes-langchain.toolCode': validateCodeTool, 547 | 'nodes-langchain.toolVectorStore': validateVectorStoreTool, 548 | 'nodes-langchain.toolWorkflow': validateWorkflowTool, 549 | 'nodes-langchain.agentTool': validateAIAgentTool, 550 | 'nodes-langchain.mcpClientTool': validateMCPClientTool, 551 | 'nodes-langchain.toolCalculator': validateCalculatorTool, 552 | 'nodes-langchain.toolThink': validateThinkTool, 553 | 'nodes-langchain.toolSerpApi': validateSerpApiTool, 554 | 'nodes-langchain.toolWikipedia': validateWikipediaTool, 555 | 'nodes-langchain.toolSearXng': validateSearXngTool, 556 | 'nodes-langchain.toolWolframAlpha': validateWolframAlphaTool, 557 | } as const; 558 | 559 | /** 560 | * Check if a node type is an AI tool sub-node 561 | */ 562 | export function isAIToolSubNode(nodeType: string): boolean { 563 | const normalized = NodeTypeNormalizer.normalizeToFullForm(nodeType); 564 | return normalized in AI_TOOL_VALIDATORS; 565 | } 566 | 567 | /** 568 | * Validate an AI tool sub-node with the appropriate validator 569 | */ 570 | export function validateAIToolSubNode( 571 | node: WorkflowNode, 572 | nodeType: string, 573 | reverseConnections: Map<string, ReverseConnection[]>, 574 | workflow: WorkflowJson 575 | ): ValidationIssue[] { 576 | const normalized = NodeTypeNormalizer.normalizeToFullForm(nodeType); 577 | 578 | // Route to appropriate validator based on node type 579 | switch (normalized) { 580 | case 'nodes-langchain.toolHttpRequest': 581 | return validateHTTPRequestTool(node); 582 | case 'nodes-langchain.toolCode': 583 | return validateCodeTool(node); 584 | case 'nodes-langchain.toolVectorStore': 585 | return validateVectorStoreTool(node, reverseConnections, workflow); 586 | case 'nodes-langchain.toolWorkflow': 587 | return validateWorkflowTool(node); 588 | case 'nodes-langchain.agentTool': 589 | return validateAIAgentTool(node, reverseConnections); 590 | case 'nodes-langchain.mcpClientTool': 591 | return validateMCPClientTool(node); 592 | case 'nodes-langchain.toolCalculator': 593 | return validateCalculatorTool(node); 594 | case 'nodes-langchain.toolThink': 595 | return validateThinkTool(node); 596 | case 'nodes-langchain.toolSerpApi': 597 | return validateSerpApiTool(node); 598 | case 'nodes-langchain.toolWikipedia': 599 | return validateWikipediaTool(node); 600 | case 'nodes-langchain.toolSearXng': 601 | return validateSearXngTool(node); 602 | case 'nodes-langchain.toolWolframAlpha': 603 | return validateWolframAlphaTool(node); 604 | default: 605 | return []; 606 | } 607 | } 608 | ``` -------------------------------------------------------------------------------- /src/services/workflow-auto-fixer.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Workflow Auto-Fixer Service 3 | * 4 | * Automatically generates fix operations for common workflow validation errors. 5 | * Converts validation results into diff operations that can be applied to fix the workflow. 6 | */ 7 | 8 | import crypto from 'crypto'; 9 | import { WorkflowValidationResult } from './workflow-validator'; 10 | import { ExpressionFormatIssue } from './expression-format-validator'; 11 | import { NodeSimilarityService } from './node-similarity-service'; 12 | import { NodeRepository } from '../database/node-repository'; 13 | import { 14 | WorkflowDiffOperation, 15 | UpdateNodeOperation 16 | } from '../types/workflow-diff'; 17 | import { WorkflowNode, Workflow } from '../types/n8n-api'; 18 | import { Logger } from '../utils/logger'; 19 | 20 | const logger = new Logger({ prefix: '[WorkflowAutoFixer]' }); 21 | 22 | export type FixConfidenceLevel = 'high' | 'medium' | 'low'; 23 | export type FixType = 24 | | 'expression-format' 25 | | 'typeversion-correction' 26 | | 'error-output-config' 27 | | 'node-type-correction' 28 | | 'webhook-missing-path'; 29 | 30 | export interface AutoFixConfig { 31 | applyFixes: boolean; 32 | fixTypes?: FixType[]; 33 | confidenceThreshold?: FixConfidenceLevel; 34 | maxFixes?: number; 35 | } 36 | 37 | export interface FixOperation { 38 | node: string; 39 | field: string; 40 | type: FixType; 41 | before: any; 42 | after: any; 43 | confidence: FixConfidenceLevel; 44 | description: string; 45 | } 46 | 47 | export interface AutoFixResult { 48 | operations: WorkflowDiffOperation[]; 49 | fixes: FixOperation[]; 50 | summary: string; 51 | stats: { 52 | total: number; 53 | byType: Record<FixType, number>; 54 | byConfidence: Record<FixConfidenceLevel, number>; 55 | }; 56 | } 57 | 58 | export interface NodeFormatIssue extends ExpressionFormatIssue { 59 | nodeName: string; 60 | nodeId: string; 61 | } 62 | 63 | /** 64 | * Type guard to check if an issue has node information 65 | */ 66 | export function isNodeFormatIssue(issue: ExpressionFormatIssue): issue is NodeFormatIssue { 67 | return 'nodeName' in issue && 'nodeId' in issue && 68 | typeof (issue as any).nodeName === 'string' && 69 | typeof (issue as any).nodeId === 'string'; 70 | } 71 | 72 | /** 73 | * Error with suggestions for node type issues 74 | */ 75 | export interface NodeTypeError { 76 | type: 'error'; 77 | nodeId?: string; 78 | nodeName?: string; 79 | message: string; 80 | suggestions?: Array<{ 81 | nodeType: string; 82 | confidence: number; 83 | reason: string; 84 | }>; 85 | } 86 | 87 | export class WorkflowAutoFixer { 88 | private readonly defaultConfig: AutoFixConfig = { 89 | applyFixes: false, 90 | confidenceThreshold: 'medium', 91 | maxFixes: 50 92 | }; 93 | private similarityService: NodeSimilarityService | null = null; 94 | 95 | constructor(repository?: NodeRepository) { 96 | if (repository) { 97 | this.similarityService = new NodeSimilarityService(repository); 98 | } 99 | } 100 | 101 | /** 102 | * Generate fix operations from validation results 103 | */ 104 | generateFixes( 105 | workflow: Workflow, 106 | validationResult: WorkflowValidationResult, 107 | formatIssues: ExpressionFormatIssue[] = [], 108 | config: Partial<AutoFixConfig> = {} 109 | ): AutoFixResult { 110 | const fullConfig = { ...this.defaultConfig, ...config }; 111 | const operations: WorkflowDiffOperation[] = []; 112 | const fixes: FixOperation[] = []; 113 | 114 | // Create a map for quick node lookup 115 | const nodeMap = new Map<string, WorkflowNode>(); 116 | workflow.nodes.forEach(node => { 117 | nodeMap.set(node.name, node); 118 | nodeMap.set(node.id, node); 119 | }); 120 | 121 | // Process expression format issues (HIGH confidence) 122 | if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('expression-format')) { 123 | this.processExpressionFormatFixes(formatIssues, nodeMap, operations, fixes); 124 | } 125 | 126 | // Process typeVersion errors (MEDIUM confidence) 127 | if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('typeversion-correction')) { 128 | this.processTypeVersionFixes(validationResult, nodeMap, operations, fixes); 129 | } 130 | 131 | // Process error output configuration issues (MEDIUM confidence) 132 | if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('error-output-config')) { 133 | this.processErrorOutputFixes(validationResult, nodeMap, workflow, operations, fixes); 134 | } 135 | 136 | // Process node type corrections (HIGH confidence only) 137 | if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('node-type-correction')) { 138 | this.processNodeTypeFixes(validationResult, nodeMap, operations, fixes); 139 | } 140 | 141 | // Process webhook path fixes (HIGH confidence) 142 | if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('webhook-missing-path')) { 143 | this.processWebhookPathFixes(validationResult, nodeMap, operations, fixes); 144 | } 145 | 146 | // Filter by confidence threshold 147 | const filteredFixes = this.filterByConfidence(fixes, fullConfig.confidenceThreshold); 148 | const filteredOperations = this.filterOperationsByFixes(operations, filteredFixes, fixes); 149 | 150 | // Apply max fixes limit 151 | const limitedFixes = filteredFixes.slice(0, fullConfig.maxFixes); 152 | const limitedOperations = this.filterOperationsByFixes(filteredOperations, limitedFixes, filteredFixes); 153 | 154 | // Generate summary 155 | const stats = this.calculateStats(limitedFixes); 156 | const summary = this.generateSummary(stats); 157 | 158 | return { 159 | operations: limitedOperations, 160 | fixes: limitedFixes, 161 | summary, 162 | stats 163 | }; 164 | } 165 | 166 | /** 167 | * Process expression format fixes (missing = prefix) 168 | */ 169 | private processExpressionFormatFixes( 170 | formatIssues: ExpressionFormatIssue[], 171 | nodeMap: Map<string, WorkflowNode>, 172 | operations: WorkflowDiffOperation[], 173 | fixes: FixOperation[] 174 | ): void { 175 | // Group fixes by node to create single update operation per node 176 | const fixesByNode = new Map<string, ExpressionFormatIssue[]>(); 177 | 178 | for (const issue of formatIssues) { 179 | // Process both errors and warnings for missing-prefix issues 180 | if (issue.issueType === 'missing-prefix') { 181 | // Use type guard to ensure we have node information 182 | if (!isNodeFormatIssue(issue)) { 183 | logger.warn('Expression format issue missing node information', { 184 | fieldPath: issue.fieldPath, 185 | issueType: issue.issueType 186 | }); 187 | continue; 188 | } 189 | 190 | const nodeName = issue.nodeName; 191 | 192 | if (!fixesByNode.has(nodeName)) { 193 | fixesByNode.set(nodeName, []); 194 | } 195 | fixesByNode.get(nodeName)!.push(issue); 196 | } 197 | } 198 | 199 | // Create update operations for each node 200 | for (const [nodeName, nodeIssues] of fixesByNode) { 201 | const node = nodeMap.get(nodeName); 202 | if (!node) continue; 203 | 204 | const updatedParameters = JSON.parse(JSON.stringify(node.parameters || {})); 205 | 206 | for (const issue of nodeIssues) { 207 | // Apply the fix to parameters 208 | // The fieldPath doesn't include node name, use as is 209 | const fieldPath = issue.fieldPath.split('.'); 210 | this.setNestedValue(updatedParameters, fieldPath, issue.correctedValue); 211 | 212 | fixes.push({ 213 | node: nodeName, 214 | field: issue.fieldPath, 215 | type: 'expression-format', 216 | before: issue.currentValue, 217 | after: issue.correctedValue, 218 | confidence: 'high', 219 | description: issue.explanation 220 | }); 221 | } 222 | 223 | // Create update operation 224 | const operation: UpdateNodeOperation = { 225 | type: 'updateNode', 226 | nodeId: nodeName, // Can be name or ID 227 | updates: { 228 | parameters: updatedParameters 229 | } 230 | }; 231 | operations.push(operation); 232 | } 233 | } 234 | 235 | /** 236 | * Process typeVersion fixes 237 | */ 238 | private processTypeVersionFixes( 239 | validationResult: WorkflowValidationResult, 240 | nodeMap: Map<string, WorkflowNode>, 241 | operations: WorkflowDiffOperation[], 242 | fixes: FixOperation[] 243 | ): void { 244 | for (const error of validationResult.errors) { 245 | if (error.message.includes('typeVersion') && error.message.includes('exceeds maximum')) { 246 | // Extract version info from error message 247 | const versionMatch = error.message.match(/typeVersion (\d+(?:\.\d+)?) exceeds maximum supported version (\d+(?:\.\d+)?)/); 248 | if (versionMatch) { 249 | const currentVersion = parseFloat(versionMatch[1]); 250 | const maxVersion = parseFloat(versionMatch[2]); 251 | const nodeName = error.nodeName || error.nodeId; 252 | 253 | if (!nodeName) continue; 254 | 255 | const node = nodeMap.get(nodeName); 256 | if (!node) continue; 257 | 258 | fixes.push({ 259 | node: nodeName, 260 | field: 'typeVersion', 261 | type: 'typeversion-correction', 262 | before: currentVersion, 263 | after: maxVersion, 264 | confidence: 'medium', 265 | description: `Corrected typeVersion from ${currentVersion} to maximum supported ${maxVersion}` 266 | }); 267 | 268 | const operation: UpdateNodeOperation = { 269 | type: 'updateNode', 270 | nodeId: nodeName, 271 | updates: { 272 | typeVersion: maxVersion 273 | } 274 | }; 275 | operations.push(operation); 276 | } 277 | } 278 | } 279 | } 280 | 281 | /** 282 | * Process error output configuration fixes 283 | */ 284 | private processErrorOutputFixes( 285 | validationResult: WorkflowValidationResult, 286 | nodeMap: Map<string, WorkflowNode>, 287 | workflow: Workflow, 288 | operations: WorkflowDiffOperation[], 289 | fixes: FixOperation[] 290 | ): void { 291 | for (const error of validationResult.errors) { 292 | if (error.message.includes('onError: \'continueErrorOutput\'') && 293 | error.message.includes('no error output connections')) { 294 | const nodeName = error.nodeName || error.nodeId; 295 | if (!nodeName) continue; 296 | 297 | const node = nodeMap.get(nodeName); 298 | if (!node) continue; 299 | 300 | // Remove the conflicting onError setting 301 | fixes.push({ 302 | node: nodeName, 303 | field: 'onError', 304 | type: 'error-output-config', 305 | before: 'continueErrorOutput', 306 | after: undefined, 307 | confidence: 'medium', 308 | description: 'Removed onError setting due to missing error output connections' 309 | }); 310 | 311 | const operation: UpdateNodeOperation = { 312 | type: 'updateNode', 313 | nodeId: nodeName, 314 | updates: { 315 | onError: undefined // This will remove the property 316 | } 317 | }; 318 | operations.push(operation); 319 | } 320 | } 321 | } 322 | 323 | /** 324 | * Process node type corrections for unknown nodes 325 | */ 326 | private processNodeTypeFixes( 327 | validationResult: WorkflowValidationResult, 328 | nodeMap: Map<string, WorkflowNode>, 329 | operations: WorkflowDiffOperation[], 330 | fixes: FixOperation[] 331 | ): void { 332 | // Only process if we have the similarity service 333 | if (!this.similarityService) { 334 | return; 335 | } 336 | 337 | for (const error of validationResult.errors) { 338 | // Type-safe check for unknown node type errors with suggestions 339 | const nodeError = error as NodeTypeError; 340 | 341 | if (error.message?.includes('Unknown node type:') && nodeError.suggestions) { 342 | // Only auto-fix if we have a high-confidence suggestion (>= 0.9) 343 | const highConfidenceSuggestion = nodeError.suggestions.find(s => s.confidence >= 0.9); 344 | 345 | if (highConfidenceSuggestion && nodeError.nodeId) { 346 | const node = nodeMap.get(nodeError.nodeId) || nodeMap.get(nodeError.nodeName || ''); 347 | 348 | if (node) { 349 | fixes.push({ 350 | node: node.name, 351 | field: 'type', 352 | type: 'node-type-correction', 353 | before: node.type, 354 | after: highConfidenceSuggestion.nodeType, 355 | confidence: 'high', 356 | description: `Fix node type: "${node.type}" → "${highConfidenceSuggestion.nodeType}" (${highConfidenceSuggestion.reason})` 357 | }); 358 | 359 | const operation: UpdateNodeOperation = { 360 | type: 'updateNode', 361 | nodeId: node.name, 362 | updates: { 363 | type: highConfidenceSuggestion.nodeType 364 | } 365 | }; 366 | operations.push(operation); 367 | } 368 | } 369 | } 370 | } 371 | } 372 | 373 | /** 374 | * Process webhook path fixes for webhook nodes missing path parameter 375 | */ 376 | private processWebhookPathFixes( 377 | validationResult: WorkflowValidationResult, 378 | nodeMap: Map<string, WorkflowNode>, 379 | operations: WorkflowDiffOperation[], 380 | fixes: FixOperation[] 381 | ): void { 382 | for (const error of validationResult.errors) { 383 | // Check for webhook path required error 384 | if (error.message === 'Webhook path is required') { 385 | const nodeName = error.nodeName || error.nodeId; 386 | if (!nodeName) continue; 387 | 388 | const node = nodeMap.get(nodeName); 389 | if (!node) continue; 390 | 391 | // Only fix webhook nodes 392 | if (!node.type?.includes('webhook')) continue; 393 | 394 | // Generate a unique UUID for both path and webhookId 395 | const webhookId = crypto.randomUUID(); 396 | 397 | // Check if we need to update typeVersion 398 | const currentTypeVersion = node.typeVersion || 1; 399 | const needsVersionUpdate = currentTypeVersion < 2.1; 400 | 401 | fixes.push({ 402 | node: nodeName, 403 | field: 'path', 404 | type: 'webhook-missing-path', 405 | before: undefined, 406 | after: webhookId, 407 | confidence: 'high', 408 | description: needsVersionUpdate 409 | ? `Generated webhook path and ID: ${webhookId} (also updating typeVersion to 2.1)` 410 | : `Generated webhook path and ID: ${webhookId}` 411 | }); 412 | 413 | // Create update operation with both path and webhookId 414 | // The updates object uses dot notation for nested properties 415 | const updates: Record<string, any> = { 416 | 'parameters.path': webhookId, 417 | 'webhookId': webhookId 418 | }; 419 | 420 | // Only update typeVersion if it's older than 2.1 421 | if (needsVersionUpdate) { 422 | updates['typeVersion'] = 2.1; 423 | } 424 | 425 | const operation: UpdateNodeOperation = { 426 | type: 'updateNode', 427 | nodeId: nodeName, 428 | updates 429 | }; 430 | operations.push(operation); 431 | } 432 | } 433 | } 434 | 435 | /** 436 | * Set a nested value in an object using a path array 437 | * Includes validation to prevent silent failures 438 | */ 439 | private setNestedValue(obj: any, path: string[], value: any): void { 440 | if (!obj || typeof obj !== 'object') { 441 | throw new Error('Cannot set value on non-object'); 442 | } 443 | 444 | if (path.length === 0) { 445 | throw new Error('Cannot set value with empty path'); 446 | } 447 | 448 | try { 449 | let current = obj; 450 | 451 | for (let i = 0; i < path.length - 1; i++) { 452 | const key = path[i]; 453 | 454 | // Handle array indices 455 | if (key.includes('[')) { 456 | const matches = key.match(/^([^[]+)\[(\d+)\]$/); 457 | if (!matches) { 458 | throw new Error(`Invalid array notation: ${key}`); 459 | } 460 | 461 | const [, arrayKey, indexStr] = matches; 462 | const index = parseInt(indexStr, 10); 463 | 464 | if (isNaN(index) || index < 0) { 465 | throw new Error(`Invalid array index: ${indexStr}`); 466 | } 467 | 468 | if (!current[arrayKey]) { 469 | current[arrayKey] = []; 470 | } 471 | 472 | if (!Array.isArray(current[arrayKey])) { 473 | throw new Error(`Expected array at ${arrayKey}, got ${typeof current[arrayKey]}`); 474 | } 475 | 476 | while (current[arrayKey].length <= index) { 477 | current[arrayKey].push({}); 478 | } 479 | 480 | current = current[arrayKey][index]; 481 | } else { 482 | if (current[key] === null || current[key] === undefined) { 483 | current[key] = {}; 484 | } 485 | 486 | if (typeof current[key] !== 'object' || Array.isArray(current[key])) { 487 | throw new Error(`Cannot traverse through ${typeof current[key]} at ${key}`); 488 | } 489 | 490 | current = current[key]; 491 | } 492 | } 493 | 494 | // Set the final value 495 | const lastKey = path[path.length - 1]; 496 | 497 | if (lastKey.includes('[')) { 498 | const matches = lastKey.match(/^([^[]+)\[(\d+)\]$/); 499 | if (!matches) { 500 | throw new Error(`Invalid array notation: ${lastKey}`); 501 | } 502 | 503 | const [, arrayKey, indexStr] = matches; 504 | const index = parseInt(indexStr, 10); 505 | 506 | if (isNaN(index) || index < 0) { 507 | throw new Error(`Invalid array index: ${indexStr}`); 508 | } 509 | 510 | if (!current[arrayKey]) { 511 | current[arrayKey] = []; 512 | } 513 | 514 | if (!Array.isArray(current[arrayKey])) { 515 | throw new Error(`Expected array at ${arrayKey}, got ${typeof current[arrayKey]}`); 516 | } 517 | 518 | while (current[arrayKey].length <= index) { 519 | current[arrayKey].push(null); 520 | } 521 | 522 | current[arrayKey][index] = value; 523 | } else { 524 | current[lastKey] = value; 525 | } 526 | } catch (error) { 527 | logger.error('Failed to set nested value', { 528 | path: path.join('.'), 529 | error: error instanceof Error ? error.message : String(error) 530 | }); 531 | throw error; 532 | } 533 | } 534 | 535 | /** 536 | * Filter fixes by confidence level 537 | */ 538 | private filterByConfidence( 539 | fixes: FixOperation[], 540 | threshold?: FixConfidenceLevel 541 | ): FixOperation[] { 542 | if (!threshold) return fixes; 543 | 544 | const levels: FixConfidenceLevel[] = ['high', 'medium', 'low']; 545 | const thresholdIndex = levels.indexOf(threshold); 546 | 547 | return fixes.filter(fix => { 548 | const fixIndex = levels.indexOf(fix.confidence); 549 | return fixIndex <= thresholdIndex; 550 | }); 551 | } 552 | 553 | /** 554 | * Filter operations to match filtered fixes 555 | */ 556 | private filterOperationsByFixes( 557 | operations: WorkflowDiffOperation[], 558 | filteredFixes: FixOperation[], 559 | allFixes: FixOperation[] 560 | ): WorkflowDiffOperation[] { 561 | const fixedNodes = new Set(filteredFixes.map(f => f.node)); 562 | return operations.filter(op => { 563 | if (op.type === 'updateNode') { 564 | return fixedNodes.has(op.nodeId || ''); 565 | } 566 | return true; 567 | }); 568 | } 569 | 570 | /** 571 | * Calculate statistics about fixes 572 | */ 573 | private calculateStats(fixes: FixOperation[]): AutoFixResult['stats'] { 574 | const stats: AutoFixResult['stats'] = { 575 | total: fixes.length, 576 | byType: { 577 | 'expression-format': 0, 578 | 'typeversion-correction': 0, 579 | 'error-output-config': 0, 580 | 'node-type-correction': 0, 581 | 'webhook-missing-path': 0 582 | }, 583 | byConfidence: { 584 | 'high': 0, 585 | 'medium': 0, 586 | 'low': 0 587 | } 588 | }; 589 | 590 | for (const fix of fixes) { 591 | stats.byType[fix.type]++; 592 | stats.byConfidence[fix.confidence]++; 593 | } 594 | 595 | return stats; 596 | } 597 | 598 | /** 599 | * Generate a human-readable summary 600 | */ 601 | private generateSummary(stats: AutoFixResult['stats']): string { 602 | if (stats.total === 0) { 603 | return 'No fixes available'; 604 | } 605 | 606 | const parts: string[] = []; 607 | 608 | if (stats.byType['expression-format'] > 0) { 609 | parts.push(`${stats.byType['expression-format']} expression format ${stats.byType['expression-format'] === 1 ? 'error' : 'errors'}`); 610 | } 611 | if (stats.byType['typeversion-correction'] > 0) { 612 | parts.push(`${stats.byType['typeversion-correction']} version ${stats.byType['typeversion-correction'] === 1 ? 'issue' : 'issues'}`); 613 | } 614 | if (stats.byType['error-output-config'] > 0) { 615 | parts.push(`${stats.byType['error-output-config']} error output ${stats.byType['error-output-config'] === 1 ? 'configuration' : 'configurations'}`); 616 | } 617 | if (stats.byType['node-type-correction'] > 0) { 618 | parts.push(`${stats.byType['node-type-correction']} node type ${stats.byType['node-type-correction'] === 1 ? 'correction' : 'corrections'}`); 619 | } 620 | if (stats.byType['webhook-missing-path'] > 0) { 621 | parts.push(`${stats.byType['webhook-missing-path']} webhook ${stats.byType['webhook-missing-path'] === 1 ? 'path' : 'paths'}`); 622 | } 623 | 624 | if (parts.length === 0) { 625 | return `Fixed ${stats.total} ${stats.total === 1 ? 'issue' : 'issues'}`; 626 | } 627 | 628 | return `Fixed ${parts.join(', ')}`; 629 | } 630 | } ``` -------------------------------------------------------------------------------- /src/services/resource-similarity-service.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { NodeRepository } from '../database/node-repository'; 2 | import { logger } from '../utils/logger'; 3 | import { ValidationServiceError } from '../errors/validation-service-error'; 4 | 5 | export interface ResourceSuggestion { 6 | value: string; 7 | confidence: number; 8 | reason: string; 9 | availableOperations?: string[]; 10 | } 11 | 12 | interface ResourcePattern { 13 | pattern: string; 14 | suggestion: string; 15 | confidence: number; 16 | reason: string; 17 | } 18 | 19 | export class ResourceSimilarityService { 20 | private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes 21 | private static readonly MIN_CONFIDENCE = 0.3; // 30% minimum confidence to suggest 22 | private static readonly MAX_SUGGESTIONS = 5; 23 | 24 | // Confidence thresholds for better code clarity 25 | private static readonly CONFIDENCE_THRESHOLDS = { 26 | EXACT: 1.0, 27 | VERY_HIGH: 0.95, 28 | HIGH: 0.8, 29 | MEDIUM: 0.6, 30 | MIN_SUBSTRING: 0.7 31 | } as const; 32 | 33 | private repository: NodeRepository; 34 | private resourceCache: Map<string, { resources: any[], timestamp: number }> = new Map(); 35 | private suggestionCache: Map<string, ResourceSuggestion[]> = new Map(); 36 | private commonPatterns: Map<string, ResourcePattern[]>; 37 | 38 | constructor(repository: NodeRepository) { 39 | this.repository = repository; 40 | this.commonPatterns = this.initializeCommonPatterns(); 41 | } 42 | 43 | /** 44 | * Clean up expired cache entries to prevent memory leaks 45 | */ 46 | private cleanupExpiredEntries(): void { 47 | const now = Date.now(); 48 | 49 | // Clean resource cache 50 | for (const [key, value] of this.resourceCache.entries()) { 51 | if (now - value.timestamp >= ResourceSimilarityService.CACHE_DURATION_MS) { 52 | this.resourceCache.delete(key); 53 | } 54 | } 55 | 56 | // Clean suggestion cache - these don't have timestamps, so clear if cache is too large 57 | if (this.suggestionCache.size > 100) { 58 | // Keep only the most recent 50 entries 59 | const entries = Array.from(this.suggestionCache.entries()); 60 | this.suggestionCache.clear(); 61 | entries.slice(-50).forEach(([key, value]) => { 62 | this.suggestionCache.set(key, value); 63 | }); 64 | } 65 | } 66 | 67 | /** 68 | * Initialize common resource mistake patterns 69 | */ 70 | private initializeCommonPatterns(): Map<string, ResourcePattern[]> { 71 | const patterns = new Map<string, ResourcePattern[]>(); 72 | 73 | // Google Drive patterns 74 | patterns.set('googleDrive', [ 75 | { pattern: 'files', suggestion: 'file', confidence: 0.95, reason: 'Use singular "file" not plural' }, 76 | { pattern: 'folders', suggestion: 'folder', confidence: 0.95, reason: 'Use singular "folder" not plural' }, 77 | { pattern: 'permissions', suggestion: 'permission', confidence: 0.9, reason: 'Use singular form' }, 78 | { pattern: 'fileAndFolder', suggestion: 'fileFolder', confidence: 0.9, reason: 'Use "fileFolder" for combined operations' }, 79 | { pattern: 'driveFiles', suggestion: 'file', confidence: 0.8, reason: 'Use "file" for file operations' }, 80 | { pattern: 'sharedDrives', suggestion: 'drive', confidence: 0.85, reason: 'Use "drive" for shared drive operations' }, 81 | ]); 82 | 83 | // Slack patterns 84 | patterns.set('slack', [ 85 | { pattern: 'messages', suggestion: 'message', confidence: 0.95, reason: 'Use singular "message" not plural' }, 86 | { pattern: 'channels', suggestion: 'channel', confidence: 0.95, reason: 'Use singular "channel" not plural' }, 87 | { pattern: 'users', suggestion: 'user', confidence: 0.95, reason: 'Use singular "user" not plural' }, 88 | { pattern: 'msg', suggestion: 'message', confidence: 0.85, reason: 'Use full "message" not abbreviation' }, 89 | { pattern: 'dm', suggestion: 'message', confidence: 0.7, reason: 'Use "message" for direct messages' }, 90 | { pattern: 'conversation', suggestion: 'channel', confidence: 0.7, reason: 'Use "channel" for conversations' }, 91 | ]); 92 | 93 | // Database patterns (postgres, mysql, mongodb) 94 | patterns.set('database', [ 95 | { pattern: 'tables', suggestion: 'table', confidence: 0.95, reason: 'Use singular "table" not plural' }, 96 | { pattern: 'queries', suggestion: 'query', confidence: 0.95, reason: 'Use singular "query" not plural' }, 97 | { pattern: 'collections', suggestion: 'collection', confidence: 0.95, reason: 'Use singular "collection" not plural' }, 98 | { pattern: 'documents', suggestion: 'document', confidence: 0.95, reason: 'Use singular "document" not plural' }, 99 | { pattern: 'records', suggestion: 'record', confidence: 0.85, reason: 'Use "record" or "document"' }, 100 | { pattern: 'rows', suggestion: 'row', confidence: 0.9, reason: 'Use singular "row"' }, 101 | ]); 102 | 103 | // Google Sheets patterns 104 | patterns.set('googleSheets', [ 105 | { pattern: 'sheets', suggestion: 'sheet', confidence: 0.95, reason: 'Use singular "sheet" not plural' }, 106 | { pattern: 'spreadsheets', suggestion: 'spreadsheet', confidence: 0.95, reason: 'Use singular "spreadsheet"' }, 107 | { pattern: 'cells', suggestion: 'cell', confidence: 0.9, reason: 'Use singular "cell"' }, 108 | { pattern: 'ranges', suggestion: 'range', confidence: 0.9, reason: 'Use singular "range"' }, 109 | { pattern: 'worksheets', suggestion: 'sheet', confidence: 0.8, reason: 'Use "sheet" for worksheet operations' }, 110 | ]); 111 | 112 | // Email patterns 113 | patterns.set('email', [ 114 | { pattern: 'emails', suggestion: 'email', confidence: 0.95, reason: 'Use singular "email" not plural' }, 115 | { pattern: 'messages', suggestion: 'message', confidence: 0.9, reason: 'Use "message" for email operations' }, 116 | { pattern: 'mails', suggestion: 'email', confidence: 0.9, reason: 'Use "email" not "mail"' }, 117 | { pattern: 'attachments', suggestion: 'attachment', confidence: 0.95, reason: 'Use singular "attachment"' }, 118 | ]); 119 | 120 | // Generic plural/singular patterns 121 | patterns.set('generic', [ 122 | { pattern: 'items', suggestion: 'item', confidence: 0.9, reason: 'Use singular form' }, 123 | { pattern: 'objects', suggestion: 'object', confidence: 0.9, reason: 'Use singular form' }, 124 | { pattern: 'entities', suggestion: 'entity', confidence: 0.9, reason: 'Use singular form' }, 125 | { pattern: 'resources', suggestion: 'resource', confidence: 0.9, reason: 'Use singular form' }, 126 | { pattern: 'elements', suggestion: 'element', confidence: 0.9, reason: 'Use singular form' }, 127 | ]); 128 | 129 | return patterns; 130 | } 131 | 132 | /** 133 | * Find similar resources for an invalid resource using pattern matching 134 | * and Levenshtein distance algorithms 135 | * 136 | * @param nodeType - The n8n node type (e.g., 'nodes-base.googleDrive') 137 | * @param invalidResource - The invalid resource provided by the user 138 | * @param maxSuggestions - Maximum number of suggestions to return (default: 5) 139 | * @returns Array of resource suggestions sorted by confidence 140 | * 141 | * @example 142 | * findSimilarResources('nodes-base.googleDrive', 'files', 3) 143 | * // Returns: [{ value: 'file', confidence: 0.95, reason: 'Use singular "file" not plural' }] 144 | */ 145 | findSimilarResources( 146 | nodeType: string, 147 | invalidResource: string, 148 | maxSuggestions: number = ResourceSimilarityService.MAX_SUGGESTIONS 149 | ): ResourceSuggestion[] { 150 | // Clean up expired cache entries periodically 151 | if (Math.random() < 0.1) { // 10% chance to cleanup on each call 152 | this.cleanupExpiredEntries(); 153 | } 154 | // Check cache first 155 | const cacheKey = `${nodeType}:${invalidResource}`; 156 | if (this.suggestionCache.has(cacheKey)) { 157 | return this.suggestionCache.get(cacheKey)!; 158 | } 159 | 160 | const suggestions: ResourceSuggestion[] = []; 161 | 162 | // Get valid resources for the node 163 | const validResources = this.getNodeResources(nodeType); 164 | 165 | // Early termination for exact match - no suggestions needed 166 | for (const resource of validResources) { 167 | const resourceValue = this.getResourceValue(resource); 168 | if (resourceValue.toLowerCase() === invalidResource.toLowerCase()) { 169 | return []; // Valid resource, no suggestions needed 170 | } 171 | } 172 | 173 | // Check for exact pattern matches first 174 | const nodePatterns = this.getNodePatterns(nodeType); 175 | for (const pattern of nodePatterns) { 176 | if (pattern.pattern.toLowerCase() === invalidResource.toLowerCase()) { 177 | // Check if the suggested resource actually exists with type safety 178 | const exists = validResources.some(r => { 179 | const resourceValue = this.getResourceValue(r); 180 | return resourceValue === pattern.suggestion; 181 | }); 182 | if (exists) { 183 | suggestions.push({ 184 | value: pattern.suggestion, 185 | confidence: pattern.confidence, 186 | reason: pattern.reason 187 | }); 188 | } 189 | } 190 | } 191 | 192 | // Handle automatic plural/singular conversion 193 | const singularForm = this.toSingular(invalidResource); 194 | const pluralForm = this.toPlural(invalidResource); 195 | 196 | for (const resource of validResources) { 197 | const resourceValue = this.getResourceValue(resource); 198 | 199 | // Check for plural/singular match 200 | if (resourceValue === singularForm || resourceValue === pluralForm) { 201 | if (!suggestions.some(s => s.value === resourceValue)) { 202 | suggestions.push({ 203 | value: resourceValue, 204 | confidence: 0.9, 205 | reason: invalidResource.endsWith('s') ? 206 | 'Use singular form for resources' : 207 | 'Incorrect plural/singular form', 208 | availableOperations: typeof resource === 'object' ? resource.operations : undefined 209 | }); 210 | } 211 | } 212 | 213 | // Calculate similarity 214 | const similarity = this.calculateSimilarity(invalidResource, resourceValue); 215 | if (similarity >= ResourceSimilarityService.MIN_CONFIDENCE) { 216 | if (!suggestions.some(s => s.value === resourceValue)) { 217 | suggestions.push({ 218 | value: resourceValue, 219 | confidence: similarity, 220 | reason: this.getSimilarityReason(similarity, invalidResource, resourceValue), 221 | availableOperations: typeof resource === 'object' ? resource.operations : undefined 222 | }); 223 | } 224 | } 225 | } 226 | 227 | // Sort by confidence and limit 228 | suggestions.sort((a, b) => b.confidence - a.confidence); 229 | const topSuggestions = suggestions.slice(0, maxSuggestions); 230 | 231 | // Cache the result 232 | this.suggestionCache.set(cacheKey, topSuggestions); 233 | 234 | return topSuggestions; 235 | } 236 | 237 | /** 238 | * Type-safe extraction of resource value from various formats 239 | * @param resource - Resource object or string 240 | * @returns The resource value as a string 241 | */ 242 | private getResourceValue(resource: any): string { 243 | if (typeof resource === 'string') { 244 | return resource; 245 | } 246 | if (typeof resource === 'object' && resource !== null) { 247 | return resource.value || ''; 248 | } 249 | return ''; 250 | } 251 | 252 | /** 253 | * Get resources for a node with caching 254 | */ 255 | private getNodeResources(nodeType: string): any[] { 256 | // Cleanup cache periodically 257 | if (Math.random() < 0.05) { // 5% chance 258 | this.cleanupExpiredEntries(); 259 | } 260 | 261 | const cacheKey = nodeType; 262 | const cached = this.resourceCache.get(cacheKey); 263 | 264 | if (cached && Date.now() - cached.timestamp < ResourceSimilarityService.CACHE_DURATION_MS) { 265 | return cached.resources; 266 | } 267 | 268 | const nodeInfo = this.repository.getNode(nodeType); 269 | if (!nodeInfo) return []; 270 | 271 | const resources: any[] = []; 272 | const resourceMap: Map<string, string[]> = new Map(); 273 | 274 | // Parse properties for resource fields 275 | try { 276 | const properties = nodeInfo.properties || []; 277 | for (const prop of properties) { 278 | if (prop.name === 'resource' && prop.options) { 279 | for (const option of prop.options) { 280 | resources.push({ 281 | value: option.value, 282 | name: option.name, 283 | operations: [] 284 | }); 285 | resourceMap.set(option.value, []); 286 | } 287 | } 288 | 289 | // Find operations for each resource 290 | if (prop.name === 'operation' && prop.displayOptions?.show?.resource) { 291 | const resourceValues = Array.isArray(prop.displayOptions.show.resource) 292 | ? prop.displayOptions.show.resource 293 | : [prop.displayOptions.show.resource]; 294 | 295 | for (const resourceValue of resourceValues) { 296 | if (resourceMap.has(resourceValue) && prop.options) { 297 | const ops = prop.options.map((op: any) => op.value); 298 | resourceMap.get(resourceValue)!.push(...ops); 299 | } 300 | } 301 | } 302 | } 303 | 304 | // Update resources with their operations 305 | for (const resource of resources) { 306 | if (resourceMap.has(resource.value)) { 307 | resource.operations = resourceMap.get(resource.value); 308 | } 309 | } 310 | 311 | // If no explicit resources, check for common patterns 312 | if (resources.length === 0) { 313 | // Some nodes don't have explicit resource fields 314 | const implicitResources = this.extractImplicitResources(properties); 315 | resources.push(...implicitResources); 316 | } 317 | } catch (error) { 318 | logger.warn(`Failed to extract resources for ${nodeType}:`, error); 319 | } 320 | 321 | // Cache and return 322 | this.resourceCache.set(cacheKey, { resources, timestamp: Date.now() }); 323 | return resources; 324 | } 325 | 326 | /** 327 | * Extract implicit resources from node properties 328 | */ 329 | private extractImplicitResources(properties: any[]): any[] { 330 | const resources: any[] = []; 331 | 332 | // Look for properties that suggest resources 333 | for (const prop of properties) { 334 | if (prop.name === 'operation' && prop.options) { 335 | // If there's no explicit resource field, operations might imply resources 336 | const resourceFromOps = this.inferResourceFromOperations(prop.options); 337 | if (resourceFromOps) { 338 | resources.push({ 339 | value: resourceFromOps, 340 | name: resourceFromOps.charAt(0).toUpperCase() + resourceFromOps.slice(1), 341 | operations: prop.options.map((op: any) => op.value) 342 | }); 343 | } 344 | } 345 | } 346 | 347 | return resources; 348 | } 349 | 350 | /** 351 | * Infer resource type from operations 352 | */ 353 | private inferResourceFromOperations(operations: any[]): string | null { 354 | // Common patterns in operation names that suggest resources 355 | const patterns = [ 356 | { keywords: ['file', 'upload', 'download'], resource: 'file' }, 357 | { keywords: ['folder', 'directory'], resource: 'folder' }, 358 | { keywords: ['message', 'send', 'reply'], resource: 'message' }, 359 | { keywords: ['channel', 'broadcast'], resource: 'channel' }, 360 | { keywords: ['user', 'member'], resource: 'user' }, 361 | { keywords: ['table', 'row', 'column'], resource: 'table' }, 362 | { keywords: ['document', 'doc'], resource: 'document' }, 363 | ]; 364 | 365 | for (const pattern of patterns) { 366 | for (const op of operations) { 367 | const opName = (op.value || op).toLowerCase(); 368 | if (pattern.keywords.some(keyword => opName.includes(keyword))) { 369 | return pattern.resource; 370 | } 371 | } 372 | } 373 | 374 | return null; 375 | } 376 | 377 | /** 378 | * Get patterns for a specific node type 379 | */ 380 | private getNodePatterns(nodeType: string): ResourcePattern[] { 381 | const patterns: ResourcePattern[] = []; 382 | 383 | // Add node-specific patterns 384 | if (nodeType.includes('googleDrive')) { 385 | patterns.push(...(this.commonPatterns.get('googleDrive') || [])); 386 | } else if (nodeType.includes('slack')) { 387 | patterns.push(...(this.commonPatterns.get('slack') || [])); 388 | } else if (nodeType.includes('postgres') || nodeType.includes('mysql') || nodeType.includes('mongodb')) { 389 | patterns.push(...(this.commonPatterns.get('database') || [])); 390 | } else if (nodeType.includes('googleSheets')) { 391 | patterns.push(...(this.commonPatterns.get('googleSheets') || [])); 392 | } else if (nodeType.includes('gmail') || nodeType.includes('email')) { 393 | patterns.push(...(this.commonPatterns.get('email') || [])); 394 | } 395 | 396 | // Always add generic patterns 397 | patterns.push(...(this.commonPatterns.get('generic') || [])); 398 | 399 | return patterns; 400 | } 401 | 402 | /** 403 | * Convert to singular form (simple heuristic) 404 | */ 405 | private toSingular(word: string): string { 406 | if (word.endsWith('ies')) { 407 | return word.slice(0, -3) + 'y'; 408 | } else if (word.endsWith('es')) { 409 | return word.slice(0, -2); 410 | } else if (word.endsWith('s') && !word.endsWith('ss')) { 411 | return word.slice(0, -1); 412 | } 413 | return word; 414 | } 415 | 416 | /** 417 | * Convert to plural form (simple heuristic) 418 | */ 419 | private toPlural(word: string): string { 420 | if (word.endsWith('y') && !['ay', 'ey', 'iy', 'oy', 'uy'].includes(word.slice(-2))) { 421 | return word.slice(0, -1) + 'ies'; 422 | } else if (word.endsWith('s') || word.endsWith('x') || word.endsWith('z') || 423 | word.endsWith('ch') || word.endsWith('sh')) { 424 | return word + 'es'; 425 | } else { 426 | return word + 's'; 427 | } 428 | } 429 | 430 | /** 431 | * Calculate similarity between two strings using Levenshtein distance 432 | */ 433 | private calculateSimilarity(str1: string, str2: string): number { 434 | const s1 = str1.toLowerCase(); 435 | const s2 = str2.toLowerCase(); 436 | 437 | // Exact match 438 | if (s1 === s2) return 1.0; 439 | 440 | // One is substring of the other 441 | if (s1.includes(s2) || s2.includes(s1)) { 442 | const ratio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length); 443 | return Math.max(ResourceSimilarityService.CONFIDENCE_THRESHOLDS.MIN_SUBSTRING, ratio); 444 | } 445 | 446 | // Calculate Levenshtein distance 447 | const distance = this.levenshteinDistance(s1, s2); 448 | const maxLength = Math.max(s1.length, s2.length); 449 | 450 | // Convert distance to similarity 451 | let similarity = 1 - (distance / maxLength); 452 | 453 | // Boost confidence for single character typos and transpositions in short words 454 | if (distance === 1 && maxLength <= 5) { 455 | similarity = Math.max(similarity, 0.75); 456 | } else if (distance === 2 && maxLength <= 5) { 457 | // Boost for transpositions (e.g., "flie" -> "file") 458 | similarity = Math.max(similarity, 0.72); 459 | } 460 | 461 | return similarity; 462 | } 463 | 464 | /** 465 | * Calculate Levenshtein distance between two strings 466 | */ 467 | private levenshteinDistance(str1: string, str2: string): number { 468 | const m = str1.length; 469 | const n = str2.length; 470 | const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); 471 | 472 | for (let i = 0; i <= m; i++) dp[i][0] = i; 473 | for (let j = 0; j <= n; j++) dp[0][j] = j; 474 | 475 | for (let i = 1; i <= m; i++) { 476 | for (let j = 1; j <= n; j++) { 477 | if (str1[i - 1] === str2[j - 1]) { 478 | dp[i][j] = dp[i - 1][j - 1]; 479 | } else { 480 | dp[i][j] = Math.min( 481 | dp[i - 1][j] + 1, // deletion 482 | dp[i][j - 1] + 1, // insertion 483 | dp[i - 1][j - 1] + 1 // substitution 484 | ); 485 | } 486 | } 487 | } 488 | 489 | return dp[m][n]; 490 | } 491 | 492 | /** 493 | * Generate a human-readable reason for the similarity 494 | * @param confidence - Similarity confidence score 495 | * @param invalid - The invalid resource string 496 | * @param valid - The valid resource string 497 | * @returns Human-readable explanation of the similarity 498 | */ 499 | private getSimilarityReason(confidence: number, invalid: string, valid: string): string { 500 | const { VERY_HIGH, HIGH, MEDIUM } = ResourceSimilarityService.CONFIDENCE_THRESHOLDS; 501 | 502 | if (confidence >= VERY_HIGH) { 503 | return 'Almost exact match - likely a typo'; 504 | } else if (confidence >= HIGH) { 505 | return 'Very similar - common variation'; 506 | } else if (confidence >= MEDIUM) { 507 | return 'Similar resource name'; 508 | } else if (invalid.includes(valid) || valid.includes(invalid)) { 509 | return 'Partial match'; 510 | } else { 511 | return 'Possibly related resource'; 512 | } 513 | } 514 | 515 | /** 516 | * Clear caches 517 | */ 518 | clearCache(): void { 519 | this.resourceCache.clear(); 520 | this.suggestionCache.clear(); 521 | } 522 | } ``` -------------------------------------------------------------------------------- /tests/unit/parsers/property-extractor.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { PropertyExtractor } from '@/parsers/property-extractor'; 3 | import { 4 | programmaticNodeFactory, 5 | declarativeNodeFactory, 6 | versionedNodeClassFactory, 7 | versionedNodeTypeClassFactory, 8 | nodeClassFactory, 9 | propertyFactory, 10 | stringPropertyFactory, 11 | numberPropertyFactory, 12 | booleanPropertyFactory, 13 | optionsPropertyFactory, 14 | collectionPropertyFactory, 15 | nestedPropertyFactory, 16 | resourcePropertyFactory, 17 | operationPropertyFactory, 18 | aiToolNodeFactory 19 | } from '@tests/fixtures/factories/parser-node.factory'; 20 | 21 | describe('PropertyExtractor', () => { 22 | let extractor: PropertyExtractor; 23 | 24 | beforeEach(() => { 25 | extractor = new PropertyExtractor(); 26 | }); 27 | 28 | describe('extractProperties', () => { 29 | it('should extract properties from programmatic node', () => { 30 | const nodeDefinition = programmaticNodeFactory.build(); 31 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 32 | 33 | const properties = extractor.extractProperties(NodeClass as any); 34 | 35 | expect(properties).toHaveLength(nodeDefinition.properties.length); 36 | expect(properties).toEqual(expect.arrayContaining( 37 | nodeDefinition.properties.map(prop => expect.objectContaining({ 38 | displayName: prop.displayName, 39 | name: prop.name, 40 | type: prop.type, 41 | default: prop.default 42 | })) 43 | )); 44 | }); 45 | 46 | it('should extract properties from versioned node latest version', () => { 47 | const versionedDef = versionedNodeClassFactory.build(); 48 | const NodeClass = class { 49 | nodeVersions = versionedDef.nodeVersions; 50 | baseDescription = versionedDef.baseDescription; 51 | }; 52 | 53 | const properties = extractor.extractProperties(NodeClass as any); 54 | 55 | // Should get properties from version 2 (latest) 56 | expect(properties).toHaveLength(versionedDef.nodeVersions![2].description.properties.length); 57 | }); 58 | 59 | it('should extract properties from instance with nodeVersions', () => { 60 | const NodeClass = class { 61 | description = { name: 'test' }; 62 | constructor() { 63 | (this as any).nodeVersions = { 64 | 1: { 65 | description: { 66 | properties: [propertyFactory.build({ name: 'v1prop' })] 67 | } 68 | }, 69 | 2: { 70 | description: { 71 | properties: [ 72 | propertyFactory.build({ name: 'v2prop1' }), 73 | propertyFactory.build({ name: 'v2prop2' }) 74 | ] 75 | } 76 | } 77 | }; 78 | } 79 | }; 80 | 81 | const properties = extractor.extractProperties(NodeClass as any); 82 | 83 | expect(properties).toHaveLength(2); 84 | expect(properties[0].name).toBe('v2prop1'); 85 | expect(properties[1].name).toBe('v2prop2'); 86 | }); 87 | 88 | it('should normalize properties to consistent structure', () => { 89 | const rawProperties = [ 90 | { 91 | displayName: 'Field 1', 92 | name: 'field1', 93 | type: 'string', 94 | default: 'value', 95 | description: 'Test field', 96 | required: true, 97 | displayOptions: { show: { resource: ['user'] } }, 98 | typeOptions: { multipleValues: true }, 99 | noDataExpression: false, 100 | extraField: 'should be removed' 101 | } 102 | ]; 103 | 104 | const NodeClass = nodeClassFactory.build({ 105 | description: { 106 | name: 'test', 107 | properties: rawProperties 108 | } 109 | }); 110 | 111 | const properties = extractor.extractProperties(NodeClass as any); 112 | 113 | expect(properties[0]).toEqual({ 114 | displayName: 'Field 1', 115 | name: 'field1', 116 | type: 'string', 117 | default: 'value', 118 | description: 'Test field', 119 | options: undefined, 120 | required: true, 121 | displayOptions: { show: { resource: ['user'] } }, 122 | typeOptions: { multipleValues: true }, 123 | noDataExpression: false 124 | }); 125 | 126 | expect(properties[0]).not.toHaveProperty('extraField'); 127 | }); 128 | 129 | it('should handle nodes without properties', () => { 130 | const NodeClass = nodeClassFactory.build({ 131 | description: { 132 | name: 'test', 133 | displayName: 'Test' 134 | // No properties field 135 | } 136 | }); 137 | 138 | const properties = extractor.extractProperties(NodeClass as any); 139 | 140 | expect(properties).toEqual([]); 141 | }); 142 | 143 | it('should handle failed instantiation', () => { 144 | const NodeClass = class { 145 | static description = { 146 | name: 'test', 147 | properties: [propertyFactory.build()] 148 | }; 149 | constructor() { 150 | throw new Error('Cannot instantiate'); 151 | } 152 | }; 153 | 154 | const properties = extractor.extractProperties(NodeClass as any); 155 | 156 | expect(properties).toHaveLength(1); // Should get static description property 157 | }); 158 | 159 | it('should extract from baseDescription when main description is missing', () => { 160 | const NodeClass = class { 161 | baseDescription = { 162 | properties: [ 163 | stringPropertyFactory.build({ name: 'baseProp' }) 164 | ] 165 | }; 166 | }; 167 | 168 | const properties = extractor.extractProperties(NodeClass as any); 169 | 170 | expect(properties).toHaveLength(1); 171 | expect(properties[0].name).toBe('baseProp'); 172 | }); 173 | 174 | it('should handle complex nested properties', () => { 175 | const nestedProp = nestedPropertyFactory.build(); 176 | const NodeClass = nodeClassFactory.build({ 177 | description: { 178 | name: 'test', 179 | properties: [nestedProp] 180 | } 181 | }); 182 | 183 | const properties = extractor.extractProperties(NodeClass as any); 184 | 185 | expect(properties).toHaveLength(1); 186 | expect(properties[0].type).toBe('collection'); 187 | expect(properties[0].options).toBeDefined(); 188 | }); 189 | 190 | it('should handle non-function node classes', () => { 191 | const nodeInstance = { 192 | description: { 193 | properties: [propertyFactory.build()] 194 | } 195 | }; 196 | 197 | const properties = extractor.extractProperties(nodeInstance as any); 198 | 199 | expect(properties).toHaveLength(1); 200 | }); 201 | }); 202 | 203 | describe('extractOperations', () => { 204 | it('should extract operations from declarative node routing', () => { 205 | const nodeDefinition = declarativeNodeFactory.build(); 206 | const NodeClass = nodeClassFactory.build({ description: nodeDefinition }); 207 | 208 | const operations = extractor.extractOperations(NodeClass as any); 209 | 210 | // Declarative node has 2 resources with 2 operations each = 4 total 211 | expect(operations.length).toBe(4); 212 | 213 | // Check that we have operations for each resource 214 | const userOps = operations.filter(op => op.resource === 'user'); 215 | const postOps = operations.filter(op => op.resource === 'post'); 216 | 217 | expect(userOps.length).toBe(2); // Create and Get 218 | expect(postOps.length).toBe(2); // Create and List 219 | 220 | // Verify operation structure 221 | expect(userOps[0]).toMatchObject({ 222 | resource: 'user', 223 | operation: expect.any(String), 224 | name: expect.any(String), 225 | action: expect.any(String) 226 | }); 227 | }); 228 | 229 | it('should extract operations when node has programmatic properties', () => { 230 | const operationProp = operationPropertyFactory.build(); 231 | const NodeClass = nodeClassFactory.build({ 232 | description: { 233 | name: 'test', 234 | properties: [operationProp] 235 | } 236 | }); 237 | 238 | const operations = extractor.extractOperations(NodeClass as any); 239 | 240 | expect(operations.length).toBe(operationProp.options!.length); 241 | operations.forEach((op, idx) => { 242 | expect(op).toMatchObject({ 243 | operation: operationProp.options![idx].value, 244 | name: operationProp.options![idx].name, 245 | description: operationProp.options![idx].description 246 | }); 247 | }); 248 | }); 249 | 250 | it('should extract operations when routing.operations structure exists', () => { 251 | const NodeClass = nodeClassFactory.build({ 252 | description: { 253 | name: 'test', 254 | routing: { 255 | operations: { 256 | create: { displayName: 'Create Item' }, 257 | update: { displayName: 'Update Item' }, 258 | delete: { displayName: 'Delete Item' } 259 | } 260 | } 261 | } 262 | }); 263 | 264 | const operations = extractor.extractOperations(NodeClass as any); 265 | 266 | // routing.operations is not currently extracted by the property extractor 267 | // It only extracts from routing.request structure 268 | expect(operations).toHaveLength(0); 269 | }); 270 | 271 | it('should handle operations when programmatic nodes have resource-based structure', () => { 272 | const resourceProp = resourcePropertyFactory.build(); 273 | const operationProp = { 274 | displayName: 'Operation', 275 | name: 'operation', 276 | type: 'options', 277 | displayOptions: { 278 | show: { 279 | resource: ['user', 'post'] 280 | } 281 | }, 282 | options: [ 283 | { name: 'Create', value: 'create', action: 'Create item' }, 284 | { name: 'Delete', value: 'delete', action: 'Delete item' } 285 | ] 286 | }; 287 | 288 | const NodeClass = nodeClassFactory.build({ 289 | description: { 290 | name: 'test', 291 | properties: [resourceProp, operationProp] 292 | } 293 | }); 294 | 295 | const operations = extractor.extractOperations(NodeClass as any); 296 | 297 | // PropertyExtractor only extracts operations, not resources 298 | // It should find the operation property and extract its options 299 | expect(operations).toHaveLength(operationProp.options.length); 300 | expect(operations[0]).toMatchObject({ 301 | operation: 'create', 302 | name: 'Create', 303 | description: undefined // action field is not mapped to description 304 | }); 305 | expect(operations[1]).toMatchObject({ 306 | operation: 'delete', 307 | name: 'Delete', 308 | description: undefined 309 | }); 310 | }); 311 | 312 | it('should return empty array when node has no operations', () => { 313 | const NodeClass = nodeClassFactory.build({ 314 | description: { 315 | name: 'test', 316 | properties: [stringPropertyFactory.build()] 317 | } 318 | }); 319 | 320 | const operations = extractor.extractOperations(NodeClass as any); 321 | 322 | expect(operations).toEqual([]); 323 | }); 324 | 325 | it('should extract operations when node has version structure', () => { 326 | const NodeClass = class { 327 | nodeVersions = { 328 | 1: { 329 | description: { 330 | properties: [] 331 | } 332 | }, 333 | 2: { 334 | description: { 335 | routing: { 336 | request: { 337 | resource: { 338 | options: [ 339 | { name: 'User', value: 'user' } 340 | ] 341 | }, 342 | operation: { 343 | options: { 344 | user: [ 345 | { name: 'Get', value: 'get', action: 'Get a user' } 346 | ] 347 | } 348 | } 349 | } 350 | } 351 | } 352 | } 353 | }; 354 | }; 355 | 356 | const operations = extractor.extractOperations(NodeClass as any); 357 | 358 | expect(operations).toHaveLength(1); 359 | expect(operations[0]).toMatchObject({ 360 | resource: 'user', 361 | operation: 'get', 362 | name: 'User - Get', 363 | action: 'Get a user' 364 | }); 365 | }); 366 | 367 | it('should handle extraction when property is named action instead of operation', () => { 368 | const actionProp = { 369 | displayName: 'Action', 370 | name: 'action', 371 | type: 'options', 372 | options: [ 373 | { name: 'Send', value: 'send' }, 374 | { name: 'Receive', value: 'receive' } 375 | ] 376 | }; 377 | 378 | const NodeClass = nodeClassFactory.build({ 379 | description: { 380 | name: 'test', 381 | properties: [actionProp] 382 | } 383 | }); 384 | 385 | const operations = extractor.extractOperations(NodeClass as any); 386 | 387 | expect(operations).toHaveLength(2); 388 | expect(operations[0].operation).toBe('send'); 389 | }); 390 | }); 391 | 392 | describe('detectAIToolCapability', () => { 393 | it('should detect AI capability when usableAsTool property is true', () => { 394 | const NodeClass = nodeClassFactory.build({ 395 | description: { 396 | name: 'test', 397 | usableAsTool: true 398 | } 399 | }); 400 | 401 | const isAITool = extractor.detectAIToolCapability(NodeClass as any); 402 | 403 | expect(isAITool).toBe(true); 404 | }); 405 | 406 | it('should detect AI capability when actions contain usableAsTool', () => { 407 | const NodeClass = nodeClassFactory.build({ 408 | description: { 409 | name: 'test', 410 | actions: [ 411 | { name: 'action1', usableAsTool: false }, 412 | { name: 'action2', usableAsTool: true } 413 | ] 414 | } 415 | }); 416 | 417 | const isAITool = extractor.detectAIToolCapability(NodeClass as any); 418 | 419 | expect(isAITool).toBe(true); 420 | }); 421 | 422 | it('should detect AI capability when versioned node has usableAsTool', () => { 423 | const NodeClass = { 424 | nodeVersions: { 425 | 1: { 426 | description: { usableAsTool: false } 427 | }, 428 | 2: { 429 | description: { usableAsTool: true } 430 | } 431 | } 432 | }; 433 | 434 | const isAITool = extractor.detectAIToolCapability(NodeClass as any); 435 | 436 | expect(isAITool).toBe(true); 437 | }); 438 | 439 | it('should detect AI capability when node name contains AI-related terms', () => { 440 | const aiNodeNames = ['openai', 'anthropic', 'huggingface', 'cohere', 'myai']; 441 | 442 | aiNodeNames.forEach(name => { 443 | const NodeClass = nodeClassFactory.build({ 444 | description: { name } 445 | }); 446 | 447 | const isAITool = extractor.detectAIToolCapability(NodeClass as any); 448 | 449 | expect(isAITool).toBe(true); 450 | }); 451 | }); 452 | 453 | it('should return false when node is not AI-related', () => { 454 | const NodeClass = nodeClassFactory.build({ 455 | description: { 456 | name: 'slack', 457 | usableAsTool: false 458 | } 459 | }); 460 | 461 | const isAITool = extractor.detectAIToolCapability(NodeClass as any); 462 | 463 | expect(isAITool).toBe(false); 464 | }); 465 | 466 | it('should return false when node has no description', () => { 467 | const NodeClass = class {}; 468 | 469 | const isAITool = extractor.detectAIToolCapability(NodeClass as any); 470 | 471 | expect(isAITool).toBe(false); 472 | }); 473 | }); 474 | 475 | describe('extractCredentials', () => { 476 | it('should extract credentials when node description contains them', () => { 477 | const credentials = [ 478 | { name: 'apiKey', required: true }, 479 | { name: 'oauth2', required: false } 480 | ]; 481 | 482 | const NodeClass = nodeClassFactory.build({ 483 | description: { 484 | name: 'test', 485 | credentials 486 | } 487 | }); 488 | 489 | const extracted = extractor.extractCredentials(NodeClass as any); 490 | 491 | expect(extracted).toEqual(credentials); 492 | }); 493 | 494 | it('should extract credentials when node has version structure', () => { 495 | const NodeClass = class { 496 | nodeVersions = { 497 | 1: { 498 | description: { 499 | credentials: [{ name: 'basic', required: true }] 500 | } 501 | }, 502 | 2: { 503 | description: { 504 | credentials: [ 505 | { name: 'oauth2', required: true }, 506 | { name: 'apiKey', required: false } 507 | ] 508 | } 509 | } 510 | }; 511 | }; 512 | 513 | const credentials = extractor.extractCredentials(NodeClass as any); 514 | 515 | expect(credentials).toHaveLength(2); 516 | expect(credentials[0].name).toBe('oauth2'); 517 | expect(credentials[1].name).toBe('apiKey'); 518 | }); 519 | 520 | it('should return empty array when node has no credentials', () => { 521 | const NodeClass = nodeClassFactory.build({ 522 | description: { 523 | name: 'test' 524 | // No credentials field 525 | } 526 | }); 527 | 528 | const credentials = extractor.extractCredentials(NodeClass as any); 529 | 530 | expect(credentials).toEqual([]); 531 | }); 532 | 533 | it('should extract credentials when only baseDescription has them', () => { 534 | const NodeClass = class { 535 | baseDescription = { 536 | credentials: [{ name: 'token', required: true }] 537 | }; 538 | }; 539 | 540 | const credentials = extractor.extractCredentials(NodeClass as any); 541 | 542 | expect(credentials).toHaveLength(1); 543 | expect(credentials[0].name).toBe('token'); 544 | }); 545 | 546 | it('should extract credentials when they are defined at instance level', () => { 547 | const NodeClass = class { 548 | constructor() { 549 | (this as any).description = { 550 | credentials: [ 551 | { name: 'jwt', required: true } 552 | ] 553 | }; 554 | } 555 | }; 556 | 557 | const credentials = extractor.extractCredentials(NodeClass as any); 558 | 559 | expect(credentials).toHaveLength(1); 560 | expect(credentials[0].name).toBe('jwt'); 561 | }); 562 | 563 | it('should return empty array when instantiation fails', () => { 564 | const NodeClass = class { 565 | constructor() { 566 | throw new Error('Cannot instantiate'); 567 | } 568 | }; 569 | 570 | const credentials = extractor.extractCredentials(NodeClass as any); 571 | 572 | expect(credentials).toEqual([]); 573 | }); 574 | }); 575 | 576 | describe('edge cases', () => { 577 | it('should handle extraction when properties are deeply nested', () => { 578 | const deepProperty = { 579 | displayName: 'Deep Options', 580 | name: 'deepOptions', 581 | type: 'collection', 582 | options: [ 583 | { 584 | displayName: 'Level 1', 585 | name: 'level1', 586 | type: 'collection', 587 | options: [ 588 | { 589 | displayName: 'Level 2', 590 | name: 'level2', 591 | type: 'collection', 592 | options: [ 593 | stringPropertyFactory.build({ name: 'deepValue' }) 594 | ] 595 | } 596 | ] 597 | } 598 | ] 599 | }; 600 | 601 | const NodeClass = nodeClassFactory.build({ 602 | description: { 603 | name: 'test', 604 | properties: [deepProperty] 605 | } 606 | }); 607 | 608 | const properties = extractor.extractProperties(NodeClass as any); 609 | 610 | expect(properties).toHaveLength(1); 611 | expect(properties[0].name).toBe('deepOptions'); 612 | expect(properties[0].options[0].options[0].options).toBeDefined(); 613 | }); 614 | 615 | it('should not throw when node structure has circular references', () => { 616 | const NodeClass = class { 617 | description: any = { name: 'test' }; 618 | constructor() { 619 | this.description.properties = [ 620 | { 621 | name: 'prop1', 622 | type: 'string', 623 | parentRef: this.description // Circular reference 624 | } 625 | ]; 626 | } 627 | }; 628 | 629 | // Should not throw or hang 630 | const properties = extractor.extractProperties(NodeClass as any); 631 | 632 | expect(properties).toBeDefined(); 633 | }); 634 | 635 | it('should extract from all sources when multiple operation types exist', () => { 636 | const NodeClass = nodeClassFactory.build({ 637 | description: { 638 | name: 'test', 639 | routing: { 640 | request: { 641 | resource: { 642 | options: [{ name: 'Resource1', value: 'res1' }] 643 | } 644 | }, 645 | operations: { 646 | custom: { displayName: 'Custom Op' } 647 | } 648 | }, 649 | properties: [ 650 | operationPropertyFactory.build() 651 | ] 652 | } 653 | }); 654 | 655 | const operations = extractor.extractOperations(NodeClass as any); 656 | 657 | // Should extract from all sources 658 | expect(operations.length).toBeGreaterThan(1); 659 | }); 660 | }); 661 | }); ``` -------------------------------------------------------------------------------- /tests/integration/database/node-repository.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import Database from 'better-sqlite3'; 3 | import { NodeRepository } from '../../../src/database/node-repository'; 4 | import { DatabaseAdapter } from '../../../src/database/database-adapter'; 5 | import { TestDatabase, TestDataGenerator, MOCK_NODES, createTestDatabaseAdapter } from './test-utils'; 6 | import { ParsedNode } from '../../../src/parsers/node-parser'; 7 | 8 | describe('NodeRepository Integration Tests', () => { 9 | let testDb: TestDatabase; 10 | let db: Database.Database; 11 | let repository: NodeRepository; 12 | let adapter: DatabaseAdapter; 13 | 14 | beforeEach(async () => { 15 | testDb = new TestDatabase({ mode: 'memory' }); 16 | db = await testDb.initialize(); 17 | adapter = createTestDatabaseAdapter(db); 18 | repository = new NodeRepository(adapter); 19 | }); 20 | 21 | afterEach(async () => { 22 | await testDb.cleanup(); 23 | }); 24 | 25 | describe('saveNode', () => { 26 | it('should save single node successfully', () => { 27 | const node = createParsedNode(MOCK_NODES.webhook); 28 | repository.saveNode(node); 29 | 30 | const saved = repository.getNode(node.nodeType); 31 | expect(saved).toBeTruthy(); 32 | expect(saved.nodeType).toBe(node.nodeType); 33 | expect(saved.displayName).toBe(node.displayName); 34 | }); 35 | 36 | it('should update existing nodes', () => { 37 | const node = createParsedNode(MOCK_NODES.webhook); 38 | 39 | // Save initial version 40 | repository.saveNode(node); 41 | 42 | // Update and save again 43 | const updated = { ...node, displayName: 'Updated Webhook' }; 44 | repository.saveNode(updated); 45 | 46 | const saved = repository.getNode(node.nodeType); 47 | expect(saved?.displayName).toBe('Updated Webhook'); 48 | 49 | // Should not create duplicate 50 | const count = repository.getNodeCount(); 51 | expect(count).toBe(1); 52 | }); 53 | 54 | it('should handle nodes with complex properties', () => { 55 | const complexNode: ParsedNode = { 56 | nodeType: 'n8n-nodes-base.complex', 57 | packageName: 'n8n-nodes-base', 58 | displayName: 'Complex Node', 59 | description: 'A complex node with many properties', 60 | category: 'automation', 61 | style: 'programmatic', 62 | isAITool: false, 63 | isTrigger: false, 64 | isWebhook: false, 65 | isVersioned: true, 66 | version: '1', 67 | documentation: 'Complex node documentation', 68 | properties: [ 69 | { 70 | displayName: 'Resource', 71 | name: 'resource', 72 | type: 'options', 73 | options: [ 74 | { name: 'User', value: 'user' }, 75 | { name: 'Post', value: 'post' } 76 | ], 77 | default: 'user' 78 | }, 79 | { 80 | displayName: 'Operation', 81 | name: 'operation', 82 | type: 'options', 83 | displayOptions: { 84 | show: { 85 | resource: ['user'] 86 | } 87 | }, 88 | options: [ 89 | { name: 'Create', value: 'create' }, 90 | { name: 'Get', value: 'get' } 91 | ] 92 | } 93 | ], 94 | operations: [ 95 | { resource: 'user', operation: 'create' }, 96 | { resource: 'user', operation: 'get' } 97 | ], 98 | credentials: [ 99 | { 100 | name: 'httpBasicAuth', 101 | required: false 102 | } 103 | ] 104 | }; 105 | 106 | repository.saveNode(complexNode); 107 | 108 | const saved = repository.getNode(complexNode.nodeType); 109 | expect(saved).toBeTruthy(); 110 | expect(saved.properties).toHaveLength(2); 111 | expect(saved.credentials).toHaveLength(1); 112 | expect(saved.operations).toHaveLength(2); 113 | }); 114 | 115 | it('should handle very large nodes', () => { 116 | const largeNode: ParsedNode = { 117 | nodeType: 'n8n-nodes-base.large', 118 | packageName: 'n8n-nodes-base', 119 | displayName: 'Large Node', 120 | description: 'A very large node', 121 | category: 'automation', 122 | style: 'programmatic', 123 | isAITool: false, 124 | isTrigger: false, 125 | isWebhook: false, 126 | isVersioned: true, 127 | version: '1', 128 | properties: Array.from({ length: 100 }, (_, i) => ({ 129 | displayName: `Property ${i}`, 130 | name: `prop${i}`, 131 | type: 'string', 132 | default: '' 133 | })), 134 | operations: [], 135 | credentials: [] 136 | }; 137 | 138 | repository.saveNode(largeNode); 139 | 140 | const saved = repository.getNode(largeNode.nodeType); 141 | expect(saved?.properties).toHaveLength(100); 142 | }); 143 | }); 144 | 145 | describe('getNode', () => { 146 | beforeEach(() => { 147 | repository.saveNode(createParsedNode(MOCK_NODES.webhook)); 148 | repository.saveNode(createParsedNode(MOCK_NODES.httpRequest)); 149 | }); 150 | 151 | it('should retrieve node by type', () => { 152 | const node = repository.getNode('n8n-nodes-base.webhook'); 153 | expect(node).toBeTruthy(); 154 | expect(node.displayName).toBe('Webhook'); 155 | expect(node.nodeType).toBe('n8n-nodes-base.webhook'); 156 | expect(node.package).toBe('n8n-nodes-base'); 157 | }); 158 | 159 | it('should return null for non-existent node', () => { 160 | const node = repository.getNode('n8n-nodes-base.nonExistent'); 161 | expect(node).toBeNull(); 162 | }); 163 | 164 | it('should handle special characters in node types', () => { 165 | const specialNode: ParsedNode = { 166 | nodeType: 'n8n-nodes-base.special-chars_v2.node', 167 | packageName: 'n8n-nodes-base', 168 | displayName: 'Special Node', 169 | description: 'Node with special characters', 170 | category: 'automation', 171 | style: 'programmatic', 172 | isAITool: false, 173 | isTrigger: false, 174 | isWebhook: false, 175 | isVersioned: true, 176 | version: '2', 177 | properties: [], 178 | operations: [], 179 | credentials: [] 180 | }; 181 | 182 | repository.saveNode(specialNode); 183 | const retrieved = repository.getNode(specialNode.nodeType); 184 | expect(retrieved).toBeTruthy(); 185 | }); 186 | }); 187 | 188 | describe('getAllNodes', () => { 189 | it('should return empty array when no nodes', () => { 190 | const nodes = repository.getAllNodes(); 191 | expect(nodes).toHaveLength(0); 192 | }); 193 | 194 | it('should return all nodes with limit', () => { 195 | const nodes = Array.from({ length: 20 }, (_, i) => 196 | createParsedNode({ 197 | ...MOCK_NODES.webhook, 198 | nodeType: `n8n-nodes-base.node${i}`, 199 | displayName: `Node ${i}` 200 | }) 201 | ); 202 | 203 | nodes.forEach(node => repository.saveNode(node)); 204 | 205 | const retrieved = repository.getAllNodes(10); 206 | expect(retrieved).toHaveLength(10); 207 | }); 208 | 209 | it('should return all nodes without limit', () => { 210 | const nodes = Array.from({ length: 20 }, (_, i) => 211 | createParsedNode({ 212 | ...MOCK_NODES.webhook, 213 | nodeType: `n8n-nodes-base.node${i}`, 214 | displayName: `Node ${i}` 215 | }) 216 | ); 217 | 218 | nodes.forEach(node => repository.saveNode(node)); 219 | 220 | const retrieved = repository.getAllNodes(); 221 | expect(retrieved).toHaveLength(20); 222 | }); 223 | 224 | it('should handle very large result sets efficiently', () => { 225 | const nodes = Array.from({ length: 1000 }, (_, i) => 226 | createParsedNode({ 227 | ...MOCK_NODES.webhook, 228 | nodeType: `n8n-nodes-base.node${i}`, 229 | displayName: `Node ${i}` 230 | }) 231 | ); 232 | 233 | const insertMany = db.transaction((nodes: ParsedNode[]) => { 234 | nodes.forEach(node => repository.saveNode(node)); 235 | }); 236 | 237 | const start = Date.now(); 238 | insertMany(nodes); 239 | const duration = Date.now() - start; 240 | 241 | expect(duration).toBeLessThan(1000); // Should complete in under 1 second 242 | 243 | const retrieved = repository.getAllNodes(); 244 | expect(retrieved).toHaveLength(1000); 245 | }); 246 | }); 247 | 248 | describe('getNodesByPackage', () => { 249 | beforeEach(() => { 250 | const nodes = [ 251 | createParsedNode({ 252 | ...MOCK_NODES.webhook, 253 | nodeType: 'n8n-nodes-base.node1', 254 | packageName: 'n8n-nodes-base' 255 | }), 256 | createParsedNode({ 257 | ...MOCK_NODES.webhook, 258 | nodeType: 'n8n-nodes-base.node2', 259 | packageName: 'n8n-nodes-base' 260 | }), 261 | createParsedNode({ 262 | ...MOCK_NODES.webhook, 263 | nodeType: '@n8n/n8n-nodes-langchain.node3', 264 | packageName: '@n8n/n8n-nodes-langchain' 265 | }) 266 | ]; 267 | nodes.forEach(node => repository.saveNode(node)); 268 | }); 269 | 270 | it('should filter nodes by package', () => { 271 | const baseNodes = repository.getNodesByPackage('n8n-nodes-base'); 272 | expect(baseNodes).toHaveLength(2); 273 | 274 | const langchainNodes = repository.getNodesByPackage('@n8n/n8n-nodes-langchain'); 275 | expect(langchainNodes).toHaveLength(1); 276 | }); 277 | 278 | it('should return empty array for non-existent package', () => { 279 | const nodes = repository.getNodesByPackage('non-existent-package'); 280 | expect(nodes).toHaveLength(0); 281 | }); 282 | }); 283 | 284 | describe('getNodesByCategory', () => { 285 | beforeEach(() => { 286 | const nodes = [ 287 | createParsedNode({ 288 | ...MOCK_NODES.webhook, 289 | nodeType: 'n8n-nodes-base.webhook', 290 | category: 'trigger' 291 | }), 292 | createParsedNode({ 293 | ...MOCK_NODES.webhook, 294 | nodeType: 'n8n-nodes-base.schedule', 295 | displayName: 'Schedule', 296 | category: 'trigger' 297 | }), 298 | createParsedNode({ 299 | ...MOCK_NODES.httpRequest, 300 | nodeType: 'n8n-nodes-base.httpRequest', 301 | category: 'automation' 302 | }) 303 | ]; 304 | nodes.forEach(node => repository.saveNode(node)); 305 | }); 306 | 307 | it('should filter nodes by category', () => { 308 | const triggers = repository.getNodesByCategory('trigger'); 309 | expect(triggers).toHaveLength(2); 310 | expect(triggers.every(n => n.category === 'trigger')).toBe(true); 311 | 312 | const automation = repository.getNodesByCategory('automation'); 313 | expect(automation).toHaveLength(1); 314 | expect(automation[0].category).toBe('automation'); 315 | }); 316 | }); 317 | 318 | describe('searchNodes', () => { 319 | beforeEach(() => { 320 | const nodes = [ 321 | createParsedNode({ 322 | ...MOCK_NODES.webhook, 323 | description: 'Starts the workflow when webhook is called' 324 | }), 325 | createParsedNode({ 326 | ...MOCK_NODES.httpRequest, 327 | description: 'Makes HTTP requests to external APIs' 328 | }), 329 | createParsedNode({ 330 | nodeType: 'n8n-nodes-base.emailSend', 331 | packageName: 'n8n-nodes-base', 332 | displayName: 'Send Email', 333 | description: 'Sends emails via SMTP protocol', 334 | category: 'communication', 335 | developmentStyle: 'programmatic', 336 | isAITool: false, 337 | isTrigger: false, 338 | isWebhook: false, 339 | isVersioned: true, 340 | version: '1', 341 | properties: [], 342 | operations: [], 343 | credentials: [] 344 | }) 345 | ]; 346 | nodes.forEach(node => repository.saveNode(node)); 347 | }); 348 | 349 | it('should search by node type', () => { 350 | const results = repository.searchNodes('webhook'); 351 | expect(results).toHaveLength(1); 352 | expect(results[0].nodeType).toBe('n8n-nodes-base.webhook'); 353 | }); 354 | 355 | it('should search by display name', () => { 356 | const results = repository.searchNodes('Send Email'); 357 | expect(results).toHaveLength(1); 358 | expect(results[0].nodeType).toBe('n8n-nodes-base.emailSend'); 359 | }); 360 | 361 | it('should search by description', () => { 362 | const results = repository.searchNodes('SMTP'); 363 | expect(results).toHaveLength(1); 364 | expect(results[0].nodeType).toBe('n8n-nodes-base.emailSend'); 365 | }); 366 | 367 | it('should handle OR mode (default)', () => { 368 | const results = repository.searchNodes('webhook email', 'OR'); 369 | expect(results).toHaveLength(2); 370 | const nodeTypes = results.map(r => r.nodeType); 371 | expect(nodeTypes).toContain('n8n-nodes-base.webhook'); 372 | expect(nodeTypes).toContain('n8n-nodes-base.emailSend'); 373 | }); 374 | 375 | it('should handle AND mode', () => { 376 | const results = repository.searchNodes('HTTP request', 'AND'); 377 | expect(results).toHaveLength(1); 378 | expect(results[0].nodeType).toBe('n8n-nodes-base.httpRequest'); 379 | }); 380 | 381 | it('should handle FUZZY mode', () => { 382 | const results = repository.searchNodes('HTT', 'FUZZY'); 383 | expect(results).toHaveLength(1); 384 | expect(results[0].nodeType).toBe('n8n-nodes-base.httpRequest'); 385 | }); 386 | 387 | it('should handle case-insensitive search', () => { 388 | const results = repository.searchNodes('WEBHOOK'); 389 | expect(results).toHaveLength(1); 390 | expect(results[0].nodeType).toBe('n8n-nodes-base.webhook'); 391 | }); 392 | 393 | it('should return empty array for no matches', () => { 394 | const results = repository.searchNodes('nonexistent'); 395 | expect(results).toHaveLength(0); 396 | }); 397 | 398 | it('should respect limit parameter', () => { 399 | // Add more nodes 400 | const nodes = Array.from({ length: 10 }, (_, i) => 401 | createParsedNode({ 402 | ...MOCK_NODES.webhook, 403 | nodeType: `n8n-nodes-base.test${i}`, 404 | displayName: `Test Node ${i}`, 405 | description: 'Test description' 406 | }) 407 | ); 408 | nodes.forEach(node => repository.saveNode(node)); 409 | 410 | const results = repository.searchNodes('test', 'OR', 5); 411 | expect(results).toHaveLength(5); 412 | }); 413 | }); 414 | 415 | describe('getAITools', () => { 416 | it('should return only AI tool nodes', () => { 417 | const nodes = [ 418 | createParsedNode({ 419 | ...MOCK_NODES.webhook, 420 | nodeType: 'n8n-nodes-base.webhook', 421 | isAITool: false 422 | }), 423 | createParsedNode({ 424 | ...MOCK_NODES.webhook, 425 | nodeType: '@n8n/n8n-nodes-langchain.agent', 426 | displayName: 'AI Agent', 427 | packageName: '@n8n/n8n-nodes-langchain', 428 | isAITool: true 429 | }), 430 | createParsedNode({ 431 | ...MOCK_NODES.webhook, 432 | nodeType: '@n8n/n8n-nodes-langchain.tool', 433 | displayName: 'AI Tool', 434 | packageName: '@n8n/n8n-nodes-langchain', 435 | isAITool: true 436 | }) 437 | ]; 438 | 439 | nodes.forEach(node => repository.saveNode(node)); 440 | 441 | const aiTools = repository.getAITools(); 442 | expect(aiTools).toHaveLength(2); 443 | expect(aiTools.every(node => node.package.includes('langchain'))).toBe(true); 444 | expect(aiTools[0].displayName).toBe('AI Agent'); 445 | expect(aiTools[1].displayName).toBe('AI Tool'); 446 | }); 447 | }); 448 | 449 | describe('getNodeCount', () => { 450 | it('should return correct node count', () => { 451 | expect(repository.getNodeCount()).toBe(0); 452 | 453 | repository.saveNode(createParsedNode(MOCK_NODES.webhook)); 454 | expect(repository.getNodeCount()).toBe(1); 455 | 456 | repository.saveNode(createParsedNode(MOCK_NODES.httpRequest)); 457 | expect(repository.getNodeCount()).toBe(2); 458 | }); 459 | }); 460 | 461 | describe('searchNodeProperties', () => { 462 | beforeEach(() => { 463 | const node: ParsedNode = { 464 | nodeType: 'n8n-nodes-base.complex', 465 | packageName: 'n8n-nodes-base', 466 | displayName: 'Complex Node', 467 | description: 'A complex node', 468 | category: 'automation', 469 | style: 'programmatic', 470 | isAITool: false, 471 | isTrigger: false, 472 | isWebhook: false, 473 | isVersioned: true, 474 | version: '1', 475 | properties: [ 476 | { 477 | displayName: 'Authentication', 478 | name: 'authentication', 479 | type: 'options', 480 | options: [ 481 | { name: 'Basic', value: 'basic' }, 482 | { name: 'OAuth2', value: 'oauth2' } 483 | ] 484 | }, 485 | { 486 | displayName: 'Headers', 487 | name: 'headers', 488 | type: 'collection', 489 | default: {}, 490 | options: [ 491 | { 492 | displayName: 'Header', 493 | name: 'header', 494 | type: 'string' 495 | } 496 | ] 497 | } 498 | ], 499 | operations: [], 500 | credentials: [] 501 | }; 502 | repository.saveNode(node); 503 | }); 504 | 505 | it('should find properties by name', () => { 506 | const results = repository.searchNodeProperties('n8n-nodes-base.complex', 'auth'); 507 | expect(results.length).toBeGreaterThan(0); 508 | expect(results.some(r => r.path.includes('authentication'))).toBe(true); 509 | }); 510 | 511 | it('should find nested properties', () => { 512 | const results = repository.searchNodeProperties('n8n-nodes-base.complex', 'header'); 513 | expect(results.length).toBeGreaterThan(0); 514 | }); 515 | 516 | it('should return empty array for non-existent node', () => { 517 | const results = repository.searchNodeProperties('non-existent', 'test'); 518 | expect(results).toHaveLength(0); 519 | }); 520 | }); 521 | 522 | describe('Transaction handling', () => { 523 | it('should handle errors gracefully', () => { 524 | // Test with a node that violates database constraints 525 | const invalidNode = { 526 | nodeType: '', // Empty string should violate PRIMARY KEY constraint 527 | packageName: null, // NULL should violate NOT NULL constraint 528 | displayName: null, // NULL should violate NOT NULL constraint 529 | description: '', 530 | category: 'automation', 531 | style: 'programmatic', 532 | isAITool: false, 533 | isTrigger: false, 534 | isWebhook: false, 535 | isVersioned: false, 536 | version: '1', 537 | properties: [], 538 | operations: [], 539 | credentials: [] 540 | } as any; 541 | 542 | expect(() => { 543 | repository.saveNode(invalidNode); 544 | }).toThrow(); 545 | 546 | // Repository should still be functional 547 | const count = repository.getNodeCount(); 548 | expect(count).toBe(0); 549 | }); 550 | 551 | it('should handle concurrent saves', () => { 552 | const node = createParsedNode(MOCK_NODES.webhook); 553 | 554 | // Simulate concurrent saves of the same node with different display names 555 | const promises = Array.from({ length: 10 }, (_, i) => { 556 | const updatedNode = { 557 | ...node, 558 | displayName: `Display ${i}` 559 | }; 560 | return Promise.resolve(repository.saveNode(updatedNode)); 561 | }); 562 | 563 | Promise.all(promises); 564 | 565 | // Should have only one node 566 | const count = repository.getNodeCount(); 567 | expect(count).toBe(1); 568 | 569 | // Should have the last update 570 | const saved = repository.getNode(node.nodeType); 571 | expect(saved).toBeTruthy(); 572 | }); 573 | }); 574 | 575 | describe('Performance characteristics', () => { 576 | it('should handle bulk operations efficiently', () => { 577 | const nodeCount = 1000; 578 | const nodes = Array.from({ length: nodeCount }, (_, i) => 579 | createParsedNode({ 580 | ...MOCK_NODES.webhook, 581 | nodeType: `n8n-nodes-base.node${i}`, 582 | displayName: `Node ${i}`, 583 | description: `Description for node ${i}` 584 | }) 585 | ); 586 | 587 | const insertMany = db.transaction((nodes: ParsedNode[]) => { 588 | nodes.forEach(node => repository.saveNode(node)); 589 | }); 590 | 591 | const start = Date.now(); 592 | insertMany(nodes); 593 | const saveDuration = Date.now() - start; 594 | 595 | expect(saveDuration).toBeLessThan(1000); // Should complete in under 1 second 596 | 597 | // Test search performance 598 | const searchStart = Date.now(); 599 | const results = repository.searchNodes('node', 'OR', 100); 600 | const searchDuration = Date.now() - searchStart; 601 | 602 | expect(searchDuration).toBeLessThan(50); // Search should be fast 603 | expect(results.length).toBe(100); // Respects limit 604 | }); 605 | }); 606 | }); 607 | 608 | // Helper function to create ParsedNode from test data 609 | function createParsedNode(data: any): ParsedNode { 610 | return { 611 | nodeType: data.nodeType, 612 | packageName: data.packageName, 613 | displayName: data.displayName, 614 | description: data.description || '', 615 | category: data.category || 'automation', 616 | style: data.developmentStyle || 'programmatic', 617 | isAITool: data.isAITool || false, 618 | isTrigger: data.isTrigger || false, 619 | isWebhook: data.isWebhook || false, 620 | isVersioned: data.isVersioned !== undefined ? data.isVersioned : true, 621 | version: data.version || '1', 622 | documentation: data.documentation || null, 623 | properties: data.properties || [], 624 | operations: data.operations || [], 625 | credentials: data.credentials || [] 626 | }; 627 | } ```