From e7bddc033e5e1eb6dbaaf7ae53aa5b88116aa4b0 Mon Sep 17 00:00:00 2001 From: standujar Date: Thu, 5 Feb 2026 17:52:54 +0100 Subject: [PATCH 1/9] feat: add modify existing workflow action and output schema validation - Add MODIFY_EXISTING_N8N_WORKFLOW action to load deployed workflows for editing - Add output schema validation to check $json expressions against upstream node schemas - Add auto-correction for invalid field references using LLM - Add explicit intent support via _options for multi-step agent control - Include parameter diff in preview response after workflow modification - Preserve workflow ID through modification flow for updates (not creates) - Add crawl-schemas script to extract output schemas from n8n-nodes-base --- .gitignore | 3 +- .../actions/createWorkflow.test.ts | 308 +++++++++++++++++ __tests__/unit/credentialResolver.test.ts | 75 ++++- __tests__/unit/outputSchema.test.ts | 193 +++++++++++ package.json | 4 +- scripts/crawl-schemas.ts | 180 ++++++++++ scripts/crawl.ts | 27 ++ scripts/tsconfig.json | 14 + src/actions/createWorkflow.ts | 97 +++++- src/actions/index.ts | 1 + src/actions/modifyExistingWorkflow.ts | 252 ++++++++++++++ src/index.ts | 2 + src/prompts/actionResponse.ts | 2 +- src/prompts/fieldCorrection.ts | 18 + src/prompts/index.ts | 1 + src/services/n8n-workflow-service.ts | 71 ++-- src/types/index.ts | 36 ++ src/utils/credentialResolver.ts | 12 +- src/utils/generation.ts | 109 +++++- src/utils/index.ts | 18 +- src/utils/outputSchema.ts | 317 ++++++++++++++++++ src/utils/workflow.ts | 95 +++++- 22 files changed, 1772 insertions(+), 63 deletions(-) create mode 100644 __tests__/unit/outputSchema.test.ts create mode 100644 scripts/crawl-schemas.ts create mode 100644 scripts/crawl.ts create mode 100644 scripts/tsconfig.json create mode 100644 src/actions/modifyExistingWorkflow.ts create mode 100644 src/prompts/fieldCorrection.ts create mode 100644 src/utils/outputSchema.ts diff --git a/.gitignore b/.gitignore index 1743225..26e5e6c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ coverage/ .temp/ tmp/ temp/ -# Generated node catalog (run `bun run crawl-nodes` to regenerate) +# Generated data files (run `bun run crawl` to regenerate) src/data/defaultNodes.json +src/data/schemaIndex.json diff --git a/__tests__/integration/actions/createWorkflow.test.ts b/__tests__/integration/actions/createWorkflow.test.ts index ee88c21..8d1a6e0 100644 --- a/__tests__/integration/actions/createWorkflow.test.ts +++ b/__tests__/integration/actions/createWorkflow.test.ts @@ -513,6 +513,314 @@ describe('CREATE_N8N_WORKFLOW action', () => { }); }); + // ========================================================================== + // EXPLICIT INTENT VIA _OPTIONS (multi-step agent support) + // ========================================================================== + + describe('handler - explicit intent via _options', () => { + function createDraftInCache(): WorkflowDraft { + return { + workflow: { + name: 'Stripe Gmail Summary', + nodes: [ + { + name: 'Schedule Trigger', + type: 'n8n-nodes-base.scheduleTrigger', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: {}, + }, + { + name: 'Gmail', + type: 'n8n-nodes-base.gmail', + typeVersion: 2, + position: [200, 0] as [number, number], + parameters: { operation: 'send' }, + credentials: { + gmailOAuth2Api: { id: '{{CREDENTIAL_ID}}', name: 'Gmail Account' }, + }, + }, + ], + connections: { + 'Schedule Trigger': { + main: [[{ node: 'Gmail', type: 'main', index: 0 }]], + }, + }, + }, + prompt: 'Send Stripe summaries via Gmail', + userId: 'user-001', + createdAt: Date.now(), + }; + } + + test('explicit intent=confirm bypasses LLM classification and deploys', async () => { + const draft = createDraftInCache(); + const mockService = createMockService(); + + // useModel returns "modify" — but should be IGNORED when explicit intent is passed + const runtime = createMockRuntime({ + services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, + useModel: createUseModelMock({ intent: 'modify', reason: 'LLM says modify' }), + cache: { 'workflow_draft:user-001': draft }, + }); + + const message = createMockMessage({ + content: { text: 'modifie le workflow pour utiliser outlook' }, // Text says modify + }); + const callback = createMockCallback(); + + // Pass explicit intent=confirm via _options + const result = await createWorkflowAction.handler( + runtime, + message, + createMockState(), + { intent: 'confirm' }, // <-- explicit intent + callback + ); + + expect(result?.success).toBe(true); + // Should deploy, not modify + expect(mockService.deployWorkflow).toHaveBeenCalledTimes(1); + expect(mockService.modifyWorkflowDraft).not.toHaveBeenCalled(); + expect(runtime.deleteCache).toHaveBeenCalled(); + }); + + test('explicit intent=cancel bypasses LLM classification and cancels', async () => { + const draft = createDraftInCache(); + const mockService = createMockService(); + + // useModel returns "confirm" — but should be IGNORED + const runtime = createMockRuntime({ + services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, + useModel: createUseModelMock({ intent: 'confirm', reason: 'LLM says confirm' }), + cache: { 'workflow_draft:user-001': draft }, + }); + + const message = createMockMessage({ + content: { text: 'yes deploy it' }, // Text says confirm + }); + const callback = createMockCallback(); + + // Pass explicit intent=cancel via _options + const result = await createWorkflowAction.handler( + runtime, + message, + createMockState(), + { intent: 'cancel' }, // <-- explicit intent + callback + ); + + expect(result?.success).toBe(true); + // Should cancel, not deploy + expect(mockService.deployWorkflow).not.toHaveBeenCalled(); + expect(runtime.deleteCache).toHaveBeenCalled(); + + const calls = (callback as any).mock.calls; + const lastText = calls[calls.length - 1][0].text; + expect(lastText).toContain('Stripe Gmail Summary'); // cancelled workflow name + }); + + test('explicit intent=modify with modification bypasses LLM classification', async () => { + const draft = createDraftInCache(); + const mockService = createMockService(); + + // useModel returns "confirm" — but should be IGNORED + const runtime = createMockRuntime({ + services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, + useModel: createUseModelMock({ intent: 'confirm', reason: 'LLM says confirm' }), + cache: { 'workflow_draft:user-001': draft }, + }); + + const message = createMockMessage({ + content: { text: 'ok' }, // Ambiguous text + }); + const callback = createMockCallback(); + + // Pass explicit intent=modify with modification via _options + const result = await createWorkflowAction.handler( + runtime, + message, + createMockState(), + { intent: 'modify', modification: 'Use Outlook instead of Gmail' }, // <-- explicit + callback + ); + + expect(result?.success).toBe(true); + expect(result?.data).toEqual({ awaitingUserInput: true }); + // Should modify, not deploy + expect(mockService.modifyWorkflowDraft).toHaveBeenCalledTimes(1); + expect(mockService.deployWorkflow).not.toHaveBeenCalled(); + + // Should use the modification from _options, not the message text + const modifyCall = (mockService.modifyWorkflowDraft as any).mock.calls[0]; + expect(modifyCall[1]).toBe('Use Outlook instead of Gmail'); + }); + + test('explicit intent=new bypasses LLM classification and generates fresh', async () => { + const draft = createDraftInCache(); + const mockService = createMockService(); + + // useModel returns "confirm" — but should be IGNORED + const runtime = createMockRuntime({ + services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, + useModel: createUseModelMock({ intent: 'confirm', reason: 'LLM says confirm' }), + cache: { 'workflow_draft:user-001': draft }, + }); + + const message = createMockMessage({ + content: { text: 'Create a Slack notification workflow' }, + }); + const callback = createMockCallback(); + + // Pass explicit intent=new via _options + const result = await createWorkflowAction.handler( + runtime, + message, + createMockState(), + { intent: 'new' }, // <-- explicit intent + callback + ); + + expect(result?.success).toBe(true); + // Should generate new workflow, not deploy existing draft + expect(mockService.generateWorkflowDraft).toHaveBeenCalledTimes(1); + expect(mockService.deployWorkflow).not.toHaveBeenCalled(); + expect(runtime.deleteCache).toHaveBeenCalled(); // Clears old draft before generating + }); + + test('invalid explicit intent falls back to LLM classification', async () => { + const draft = createDraftInCache(); + const mockService = createMockService(); + + // useModel returns "cancel" + const runtime = createMockRuntime({ + services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, + useModel: createUseModelMock({ intent: 'cancel', reason: 'LLM says cancel' }), + cache: { 'workflow_draft:user-001': draft }, + }); + + const message = createMockMessage({ + content: { text: 'cancel it' }, + }); + const callback = createMockCallback(); + + // Pass invalid intent via _options — should be ignored + const result = await createWorkflowAction.handler( + runtime, + message, + createMockState(), + { intent: 'invalid_intent' }, // <-- invalid, falls back to LLM + callback + ); + + expect(result?.success).toBe(true); + // Should use LLM result (cancel) + expect(mockService.deployWorkflow).not.toHaveBeenCalled(); + expect(runtime.deleteCache).toHaveBeenCalled(); + }); + + test('no _options falls back to LLM classification', async () => { + const draft = createDraftInCache(); + const mockService = createMockService(); + + const runtime = createMockRuntime({ + services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, + useModel: createUseModelMock({ intent: 'confirm', reason: 'User agreed' }), + cache: { 'workflow_draft:user-001': draft }, + }); + + const message = createMockMessage({ + content: { text: 'yes deploy' }, + }); + const callback = createMockCallback(); + + // No _options passed — uses LLM + const result = await createWorkflowAction.handler( + runtime, + message, + createMockState(), + undefined, // <-- no options + callback + ); + + expect(result?.success).toBe(true); + expect(mockService.deployWorkflow).toHaveBeenCalledTimes(1); + }); + }); + + // ========================================================================== + // MODIFY INCLUDES CHANGES IN PREVIEW + // ========================================================================== + + describe('handler - modify includes changes in preview', () => { + test('preview data includes changed parameters after modify', async () => { + const draft: WorkflowDraft = { + workflow: { + name: 'Gmail Forward', + nodes: [ + { + name: 'Gmail Trigger', + type: 'n8n-nodes-base.gmailTrigger', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: { pollTimes: { item: [{ mode: 'everyMinute' }] } }, + }, + { + name: 'Forward Email', + type: 'n8n-nodes-base.gmail', + typeVersion: 2, + position: [200, 0] as [number, number], + parameters: { operation: 'send', sendTo: 'old@example.com' }, + credentials: { gmailOAuth2Api: { id: 'cred-1', name: 'Gmail' } }, + }, + ], + connections: { + 'Gmail Trigger': { main: [[{ node: 'Forward Email', type: 'main', index: 0 }]] }, + }, + }, + prompt: 'Forward emails', + userId: 'user-001', + createdAt: Date.now(), + }; + + const modifiedWorkflow = { + ...draft.workflow, + nodes: [ + draft.workflow.nodes[0], + { + ...draft.workflow.nodes[1], + parameters: { operation: 'send', sendTo: 'new@example.com' }, + }, + ], + }; + + const mockService = createMockService({ + modifyWorkflowDraft: mock(() => Promise.resolve(modifiedWorkflow)), + }); + + const runtime = createMockRuntime({ + services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, + useModel: createUseModelMock({ intent: 'modify', reason: 'User wants to modify' }), + cache: { 'workflow_draft:user-001': draft }, + }); + + const callback = createMockCallback(); + + await createWorkflowAction.handler( + runtime, + createMockMessage({ content: { text: 'change email to new@example.com' } }), + createMockState(), + { intent: 'modify', modification: 'change email to new@example.com' }, + callback + ); + + // The callback text should contain the new email (changes are passed to formatActionResponse) + const calls = (callback as any).mock.calls; + const lastText = calls[calls.length - 1][0].text; + expect(lastText).toContain('new@example.com'); + }); + }); + // ========================================================================== // CALLBACK SUCCESS STATUS TESTS // ========================================================================== diff --git a/__tests__/unit/credentialResolver.test.ts b/__tests__/unit/credentialResolver.test.ts index 29a3fab..665abe4 100644 --- a/__tests__/unit/credentialResolver.test.ts +++ b/__tests__/unit/credentialResolver.test.ts @@ -47,6 +47,7 @@ function createMockApiClient(overrides?: Partial): N8nApiClient { } const baseConfig: N8nPluginConfig = { apiKey: 'key', host: 'http://localhost' }; +const testTagName = 'user:user-001'; // ============================================================================ // resolveCredentials @@ -58,7 +59,15 @@ describe('resolveCredentials', () => { nodes: [createTriggerNode(), { ...createGmailNode(), credentials: undefined }], }); - const res = await resolveCredentials(workflow, 'user-001', baseConfig, null, null, null); + const res = await resolveCredentials( + workflow, + 'user-001', + baseConfig, + null, + null, + null, + testTagName + ); expect(res.missingConnections).toHaveLength(0); expect(res.injectedCredentials.size).toBe(0); }); @@ -70,7 +79,8 @@ describe('resolveCredentials', () => { baseConfig, null, null, - null + null, + testTagName ); expect(res.missingConnections.length).toBeGreaterThan(0); expect(res.missingConnections[0].credType).toBe('gmailOAuth2Api'); @@ -92,7 +102,8 @@ describe('resolveCredentials', () => { config, null, null, - null + null, + testTagName ); expect(res.injectedCredentials.get('gmailOAuth2Api')).toBe('preconfigured-cred-id'); const gmailNode = res.workflow.nodes.find((n) => n.name === 'Gmail'); @@ -111,7 +122,8 @@ describe('resolveCredentials', () => { config, null, null, - null + null, + testTagName ); expect(res.injectedCredentials.get('gmailOAuth2Api')).toBe('gmail-cred-from-config'); expect(res.missingConnections).toHaveLength(0); @@ -132,7 +144,15 @@ describe('resolveCredentials', () => { credentials: { gmailOAuth2Api: 'gmail-cred-with-api' }, }; - const res = await resolveCredentials(workflow, 'user-001', config, null, null, null); + const res = await resolveCredentials( + workflow, + 'user-001', + config, + null, + null, + null, + testTagName + ); expect(res.injectedCredentials.get('gmailOAuth2')).toBe('gmail-cred-with-api'); expect(res.missingConnections).toHaveLength(0); }); @@ -146,7 +166,15 @@ describe('resolveCredentials', () => { credentials: { gmailOAuth2Api: 'gmail-cred', slackApi: 'slack-cred' }, }; - const res = await resolveCredentials(workflow, 'user-001', config, null, null, null); + const res = await resolveCredentials( + workflow, + 'user-001', + config, + null, + null, + null, + testTagName + ); expect(res.injectedCredentials.get('gmailOAuth2Api')).toBe('gmail-cred'); expect(res.injectedCredentials.get('slackApi')).toBe('slack-cred'); }); @@ -166,7 +194,8 @@ describe('resolveCredentials', () => { baseConfig, credStore, null, - null + null, + testTagName ); expect(res.injectedCredentials.get('gmailOAuth2Api')).toBe('cached-cred-id'); expect(res.missingConnections).toHaveLength(0); @@ -187,7 +216,8 @@ describe('resolveCredentials', () => { config, credStore, null, - null + null, + testTagName ); expect(res.injectedCredentials.get('gmailOAuth2Api')).toBe('db-cred-id'); }); @@ -217,11 +247,12 @@ describe('resolveCredentials', () => { baseConfig, credStore, provider, - apiClient + apiClient, + testTagName ); expect(apiClient.createCredential).toHaveBeenCalledWith({ - name: 'gmailOAuth2Api', + name: `gmailOAuth2Api_${testTagName}`, type: 'gmailOAuth2Api', data: oauthData, }); @@ -244,7 +275,8 @@ describe('resolveCredentials', () => { baseConfig, credStore, provider, - apiClient + apiClient, + testTagName ); expect(credStore.set).toHaveBeenCalledWith('user-001', 'gmailOAuth2Api', 'n8n-cred-123'); @@ -262,7 +294,8 @@ describe('resolveCredentials', () => { baseConfig, null, provider, - null + null, + testTagName ); expect(res.missingConnections.length).toBeGreaterThan(0); @@ -286,7 +319,8 @@ describe('resolveCredentials', () => { baseConfig, null, provider, - apiClient + apiClient, + testTagName ); expect(res.missingConnections.length).toBeGreaterThan(0); @@ -309,7 +343,8 @@ describe('resolveCredentials', () => { baseConfig, null, provider, - null + null, + testTagName ); expect(res.missingConnections.length).toBeGreaterThan(0); expect(res.missingConnections[0].authUrl).toBe('https://auth.example.com/connect'); @@ -328,7 +363,8 @@ describe('resolveCredentials', () => { baseConfig, null, provider, - null + null, + testTagName ); expect(res.missingConnections.length).toBeGreaterThan(0); expect(res.missingConnections[0].authUrl).toBeUndefined(); @@ -345,7 +381,8 @@ describe('resolveCredentials', () => { baseConfig, null, provider, - null + null, + testTagName ); expect(res.missingConnections.length).toBeGreaterThan(0); }); @@ -373,7 +410,8 @@ describe('resolveCredentials', () => { config, credStore, provider, - null + null, + testTagName ); expect(res.injectedCredentials.get('gmailOAuth2Api')).toBe('db-wins'); expect(provider.resolve).not.toHaveBeenCalled(); @@ -396,7 +434,8 @@ describe('resolveCredentials', () => { config, credStore, provider, - null + null, + testTagName ); expect(res.injectedCredentials.get('gmailOAuth2Api')).toBe('config-wins'); expect(provider.resolve).not.toHaveBeenCalled(); diff --git a/__tests__/unit/outputSchema.test.ts b/__tests__/unit/outputSchema.test.ts new file mode 100644 index 0000000..b855dac --- /dev/null +++ b/__tests__/unit/outputSchema.test.ts @@ -0,0 +1,193 @@ +import { describe, test, expect } from 'bun:test'; +import { + hasOutputSchema, + loadOutputSchema, + getTopLevelFields, + getAllFieldPaths, + parseExpressions, + fieldExistsInSchema, + formatSchemaForPrompt, +} from '../../src/utils/outputSchema'; +import type { SchemaContent } from '../../src/types/index'; + +// Mock schema for testing +const mockGmailMessageSchema: SchemaContent = { + type: 'object', + properties: { + id: { type: 'string' }, + subject: { type: 'string' }, + from: { + type: 'object', + properties: { + text: { type: 'string' }, + value: { + type: 'array', + items: { + type: 'object', + properties: { + address: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }, + }, + }, + labelIds: { + type: 'array', + items: { type: 'string' }, + }, + }, +}; + +describe('hasOutputSchema', () => { + test('returns true for node with schema', () => { + expect(hasOutputSchema('n8n-nodes-base.gmail')).toBe(true); + }); + + test('returns false for unknown node', () => { + expect(hasOutputSchema('n8n-nodes-base.unknownNode')).toBe(false); + }); +}); + +describe('loadOutputSchema', () => { + test('loads Gmail message/getAll schema', () => { + const result = loadOutputSchema('n8n-nodes-base.gmail', 'message', 'getAll'); + expect(result).not.toBeNull(); + expect(result!.fields).toContain('from'); + expect(result!.fields).toContain('subject'); + }); + + test('returns null for unknown node', () => { + const result = loadOutputSchema('n8n-nodes-base.unknownNode', 'resource', 'op'); + expect(result).toBeNull(); + }); + + test('returns null for unknown resource', () => { + const result = loadOutputSchema('n8n-nodes-base.gmail', 'unknownResource', 'op'); + expect(result).toBeNull(); + }); + + test('returns null for unknown operation', () => { + const result = loadOutputSchema('n8n-nodes-base.gmail', 'message', 'unknownOp'); + expect(result).toBeNull(); + }); +}); + +describe('getTopLevelFields', () => { + test('extracts top-level field names', () => { + const fields = getTopLevelFields(mockGmailMessageSchema); + expect(fields).toContain('id'); + expect(fields).toContain('subject'); + expect(fields).toContain('from'); + expect(fields).toContain('labelIds'); + }); + + test('returns empty array for schema without properties', () => { + const fields = getTopLevelFields({ type: 'string' }); + expect(fields).toEqual([]); + }); +}); + +describe('getAllFieldPaths', () => { + test('includes top-level and nested paths', () => { + const paths = getAllFieldPaths(mockGmailMessageSchema); + expect(paths).toContain('id'); + expect(paths).toContain('from'); + expect(paths).toContain('from.text'); + expect(paths).toContain('from.value'); + expect(paths).toContain('from.value[0].address'); + expect(paths).toContain('from.value[0].name'); + }); +}); + +describe('parseExpressions', () => { + test('extracts simple $json references', () => { + const params = { message: '{{ $json.subject }}' }; + const refs = parseExpressions(params); + expect(refs).toHaveLength(1); + expect(refs[0].field).toBe('subject'); + expect(refs[0].path).toEqual(['subject']); + }); + + test('extracts nested field references', () => { + const params = { to: '{{ $json.from.value[0].address }}' }; + const refs = parseExpressions(params); + expect(refs).toHaveLength(1); + expect(refs[0].field).toBe('from.value[0].address'); + expect(refs[0].path).toEqual(['from', 'value', '0', 'address']); + }); + + test('extracts multiple expressions from same string', () => { + const params = { message: 'From: {{ $json.from.text }} Subject: {{ $json.subject }}' }; + const refs = parseExpressions(params); + expect(refs).toHaveLength(2); + }); + + test('extracts from nested parameters', () => { + const params = { + options: { + body: '{{ $json.id }}', + }, + }; + const refs = parseExpressions(params); + expect(refs).toHaveLength(1); + expect(refs[0].paramPath).toBe('options.body'); + }); + + test('extracts named node references', () => { + const params = { message: "{{ $('Gmail').item.json.subject }}" }; + const refs = parseExpressions(params); + expect(refs).toHaveLength(1); + expect(refs[0].field).toBe('subject'); + }); + + test('returns empty array for no expressions', () => { + const params = { message: 'Hello world' }; + const refs = parseExpressions(params); + expect(refs).toHaveLength(0); + }); +}); + +describe('fieldExistsInSchema', () => { + test('finds top-level field', () => { + expect(fieldExistsInSchema(['subject'], mockGmailMessageSchema)).toBe(true); + }); + + test('finds nested object field', () => { + expect(fieldExistsInSchema(['from', 'text'], mockGmailMessageSchema)).toBe(true); + }); + + test('finds array item field', () => { + expect(fieldExistsInSchema(['from', 'value', '0', 'address'], mockGmailMessageSchema)).toBe( + true + ); + }); + + test('returns false for non-existent field', () => { + expect(fieldExistsInSchema(['sender'], mockGmailMessageSchema)).toBe(false); + }); + + test('returns false for non-existent nested field', () => { + expect(fieldExistsInSchema(['from', 'email'], mockGmailMessageSchema)).toBe(false); + }); + + test('returns false for empty path', () => { + expect(fieldExistsInSchema([], mockGmailMessageSchema)).toBe(false); + }); +}); + +describe('formatSchemaForPrompt', () => { + test('formats schema as field list', () => { + const formatted = formatSchemaForPrompt(mockGmailMessageSchema); + expect(formatted).toContain('id: string'); + expect(formatted).toContain('subject: string'); + expect(formatted).toContain('from: object'); + expect(formatted).toContain('from.value: array of objects'); + }); + + test('respects maxDepth', () => { + const formatted = formatSchemaForPrompt(mockGmailMessageSchema, 1); + expect(formatted).toContain('from: object'); + expect(formatted).not.toContain('from.value[0].address'); + }); +}); diff --git a/package.json b/package.json index d440bfc..678015c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "lint": "eslint src/**/*.ts", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", - "crawl-nodes": "bun run scripts/crawl-nodes.ts" + "crawl": "bun run scripts/crawl.ts", + "crawl:nodes": "bun run scripts/crawl-nodes.ts", + "crawl:schemas": "bun run scripts/crawl-schemas.ts" }, "keywords": [ "elizaos", diff --git a/scripts/crawl-schemas.ts b/scripts/crawl-schemas.ts new file mode 100644 index 0000000..14c4077 --- /dev/null +++ b/scripts/crawl-schemas.ts @@ -0,0 +1,180 @@ +/** + * Extract output schemas from n8n-nodes-base for expression validation. + * + * Embeds the full schema content (not just paths) so validation works + * at runtime without needing n8n-nodes-base installed. + * + * Run with: bun run crawl:schemas + */ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const OUTPUT = path.resolve(import.meta.dir, '..', 'src', 'data', 'schemaIndex.json'); + +// Full schema content embedded +interface SchemaContent { + type: string; + properties?: Record; + [key: string]: unknown; +} + +interface SchemaEntry { + folder: string; // Relative path from nodes/ (e.g., "Google/Gmail") + schemas: Record>; // resource → operation → full schema +} + +interface SchemaIndex { + nodeTypes: Record; + generatedAt: string; + version: string; +} + +async function findNodesBasePath(): Promise { + try { + const resolved = require.resolve('n8n-nodes-base'); + return path.join(resolved, '..', 'dist', 'nodes'); + } catch { + console.error('n8n-nodes-base not found. Run: bun add -d n8n-nodes-base'); + process.exit(1); + } +} + +async function* walkDir(dir: string): AsyncGenerator { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* walkDir(fullPath); + } else { + yield fullPath; + } + } +} + +async function scanSchemaFolder( + schemaDir: string +): Promise>> { + const schemas: Record> = {}; + + try { + // Schemas are in: __schema__/v{version}/{resource}/{operation}.json + // We take the highest version if multiple exist + const versionDirs = await readdir(schemaDir, { withFileTypes: true }); + + // Sort versions descending to get highest first + const sortedVersions = versionDirs + .filter((d) => d.isDirectory() && d.name.startsWith('v')) + .map((d) => d.name) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + + for (const versionName of sortedVersions) { + const versionPath = path.join(schemaDir, versionName); + const resourceDirs = await readdir(versionPath, { withFileTypes: true }); + + for (const resourceDir of resourceDirs) { + if (!resourceDir.isDirectory()) continue; + + const resource = resourceDir.name; + const resourcePath = path.join(versionPath, resource); + const operationFiles = await readdir(resourcePath, { withFileTypes: true }); + + if (!schemas[resource]) { + schemas[resource] = {}; + } + + for (const opFile of operationFiles) { + if (!opFile.isFile() || !opFile.name.endsWith('.json')) continue; + + const operation = opFile.name.replace('.json', ''); + + // Only take first (highest version) schema for each operation + if (schemas[resource][operation]) continue; + + try { + const schemaPath = path.join(resourcePath, opFile.name); + const content = await readFile(schemaPath, 'utf-8'); + const schema = JSON.parse(content) as SchemaContent; + schemas[resource][operation] = schema; + } catch { + // Skip invalid schema files + } + } + } + } + } catch { + // No schemas or error reading + } + + return schemas; +} + +async function main() { + const nodesPath = await findNodesBasePath(); + console.log(`Scanning ${nodesPath} for schemas...`); + + const index: SchemaIndex = { + nodeTypes: {}, + generatedAt: new Date().toISOString(), + version: '2.0.0', // Version bump: now includes full schema content + }; + + // Find all .node.json files + const nodeJsonFiles: string[] = []; + for await (const filePath of walkDir(nodesPath)) { + if (filePath.endsWith('.node.json')) { + nodeJsonFiles.push(filePath); + } + } + + console.log(`Found ${nodeJsonFiles.length} node definition files`); + + let nodesWithSchemas = 0; + let totalSchemas = 0; + + for (const nodeJsonPath of nodeJsonFiles) { + try { + const content = await readFile(nodeJsonPath, 'utf-8'); + const nodeJson = JSON.parse(content); + const nodeType = nodeJson.node as string; + + if (!nodeType) continue; + + // Get folder path relative to nodes/ + const nodeDir = path.dirname(nodeJsonPath); + const relativeFolder = path.relative(nodesPath, nodeDir); + + // Check for __schema__ folder + const schemaDir = path.join(nodeDir, '__schema__'); + const schemas = await scanSchemaFolder(schemaDir); + + const schemaCount = Object.values(schemas).reduce( + (sum, ops) => sum + Object.keys(ops).length, + 0 + ); + + if (schemaCount > 0) { + index.nodeTypes[nodeType] = { + folder: relativeFolder, + schemas, + }; + nodesWithSchemas++; + totalSchemas += schemaCount; + } + } catch { + // Skip invalid files + } + } + + await mkdir(path.dirname(OUTPUT), { recursive: true }); + await writeFile(OUTPUT, JSON.stringify(index, null, 2), 'utf-8'); + + console.log(`\nSchema index created:`); + console.log(` - Nodes with schemas: ${nodesWithSchemas}`); + console.log(` - Total schema files: ${totalSchemas}`); + console.log(` - Output: ${OUTPUT}`); +} + +main().catch((err) => { + console.error('crawl-schemas failed:', err); + process.exit(1); +}); diff --git a/scripts/crawl.ts b/scripts/crawl.ts new file mode 100644 index 0000000..4bf2684 --- /dev/null +++ b/scripts/crawl.ts @@ -0,0 +1,27 @@ +/** + * Master crawl script - generates all data indexes from n8n-nodes-base. + * + * Run with: bun run crawl + * + * Generates: + * - src/data/defaultNodes.json (node catalog) + * - src/data/schemaIndex.json (output schema index) + */ + +async function main() { + console.log('=== Crawling n8n-nodes-base ===\n'); + + // Run crawl-nodes + console.log('1/2: Crawling node definitions...'); + await import('./crawl-nodes'); + + console.log('\n2/2: Crawling output schemas...'); + await import('./crawl-schemas'); + + console.log('\n=== Done ==='); +} + +main().catch((err) => { + console.error('Crawl failed:', err); + process.exit(1); +}); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..7d79e58 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["bun-types", "node"] + }, + "include": ["./**/*.ts"] +} diff --git a/src/actions/createWorkflow.ts b/src/actions/createWorkflow.ts index 9a62f4d..9768aac 100644 --- a/src/actions/createWorkflow.ts +++ b/src/actions/createWorkflow.ts @@ -85,6 +85,32 @@ function buildPreviewData(workflow: N8nWorkflow): Record { }; } +function diffNodeParams( + before: N8nWorkflow, + after: N8nWorkflow +): Record> { + const changes: Record> = {}; + + for (const afterNode of after.nodes) { + const beforeNode = before.nodes.find((n) => n.name === afterNode.name); + const afterParams = (afterNode.parameters || {}) as Record; + const beforeParams = (beforeNode?.parameters || {}) as Record; + + const nodeChanges: Record = {}; + for (const [key, value] of Object.entries(afterParams)) { + if (JSON.stringify(value) !== JSON.stringify(beforeParams[key])) { + nodeChanges[key] = value; + } + } + + if (Object.keys(nodeChanges).length > 0) { + changes[afterNode.name] = nodeChanges; + } + } + + return changes; +} + const examples: ActionExample[][] = [ [ { @@ -216,7 +242,9 @@ const examples: ActionExample[][] = [ ], ]; -export const createWorkflowAction: Action = { +export const createWorkflowAction: Action & { + parameters?: Record; +} = { name: 'CREATE_N8N_WORKFLOW', similes: [ 'CREATE_WORKFLOW', @@ -238,6 +266,21 @@ export const createWorkflowAction: Action = { 'about the draft — including "yes", "ok", "deploy it", "cancel", or modification requests. ' + 'Never reply with text only when a draft is pending.', + parameters: { + intent: { + type: 'string', + description: + 'Explicit intent when a draft is pending. Use "confirm" to deploy, "cancel" to discard, ' + + '"modify" to change the draft, or "new" to start fresh. Required when draft exists.', + required: false, + }, + modification: { + type: 'string', + description: 'The modification request when intent is "modify". Describes what to change.', + required: false, + }, + }, + validate: async (runtime: IAgentRuntime): Promise => { return !!runtime.getService(N8N_WORKFLOW_SERVICE_TYPE); }, @@ -265,12 +308,24 @@ export const createWorkflowAction: Action = { return { success: false }; } - const content = message.content as Content; + const content = message.content as Content & { + actionParams?: { intent?: string; modification?: string }; + actionInput?: { intent?: string; modification?: string }; + }; const userText = (content.text ?? '').trim(); const userId = message.entityId; const cacheKey = `workflow_draft:${userId}`; const generationContext = buildConversationContext(message, state); + // Extract action parameters from multiple sources (multi-step agent, direct call, state) + const actionParams = + content.actionParams || + content.actionInput || + (state?.data as { actionParams?: { intent?: string; modification?: string } } | undefined) + ?.actionParams || + (_options as { intent?: string; modification?: string } | undefined) || + {}; + try { let existingDraft = await runtime.getCache(cacheKey); @@ -281,11 +336,27 @@ export const createWorkflowAction: Action = { } if (existingDraft) { - const intentResult = await classifyDraftIntent(runtime, userText, existingDraft); - logger.info( - { src: 'plugin:n8n-workflow:action:create' }, - `Draft intent: ${intentResult.intent} — ${intentResult.reason}` - ); + const explicitIntent = actionParams.intent; + + let intentResult: { intent: string; reason: string; modificationRequest?: string }; + + if (explicitIntent && ['confirm', 'cancel', 'modify', 'new'].includes(explicitIntent)) { + intentResult = { + intent: explicitIntent, + reason: 'Explicit intent from action parameters', + modificationRequest: actionParams.modification, + }; + logger.info( + { src: 'plugin:n8n-workflow:action:create' }, + `Using explicit intent: ${explicitIntent}` + ); + } else { + intentResult = await classifyDraftIntent(runtime, userText, existingDraft); + logger.info( + { src: 'plugin:n8n-workflow:action:create' }, + `Draft intent: ${intentResult.intent} — ${intentResult.reason}` + ); + } // If the draft was awaiting clarification and the user answered, treat "confirm" as "modify" // to regenerate with the user's answers instead of deploying an incomplete draft. @@ -375,11 +446,13 @@ export const createWorkflowAction: Action = { return { success: true, data: { awaitingUserInput: true } }; } - const text = await formatActionResponse( - runtime, - 'PREVIEW', - buildPreviewData(modifiedWorkflow) - ); + const previewData = buildPreviewData(modifiedWorkflow); + const changes = diffNodeParams(existingDraft.workflow, modifiedWorkflow); + if (Object.keys(changes).length > 0) { + previewData.changes = changes; + } + + const text = await formatActionResponse(runtime, 'PREVIEW', previewData); if (callback) { await callback({ text, success: true }); } diff --git a/src/actions/index.ts b/src/actions/index.ts index b18396d..859fb5c 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -3,3 +3,4 @@ export { getExecutionsAction } from './getExecutions'; export { activateWorkflowAction } from './activateWorkflow'; export { deactivateWorkflowAction } from './deactivateWorkflow'; export { deleteWorkflowAction } from './deleteWorkflow'; +export { modifyExistingWorkflowAction } from './modifyExistingWorkflow'; diff --git a/src/actions/modifyExistingWorkflow.ts b/src/actions/modifyExistingWorkflow.ts new file mode 100644 index 0000000..861ecb5 --- /dev/null +++ b/src/actions/modifyExistingWorkflow.ts @@ -0,0 +1,252 @@ +import { + type Action, + type ActionExample, + type ActionResult, + type HandlerCallback, + type IAgentRuntime, + logger, + type Memory, + type State, +} from '@elizaos/core'; +import { N8N_WORKFLOW_SERVICE_TYPE, type N8nWorkflowService } from '../services/index'; +import type { WorkflowDraft } from '../types/index'; +import { matchWorkflow, formatActionResponse } from '../utils/generation'; +import { buildConversationContext } from '../utils/context'; + +const DRAFT_TTL_MS = 30 * 60 * 1000; + +const examples: ActionExample[][] = [ + [ + { + name: '{{user1}}', + content: { + text: 'Modify my email notification workflow', + }, + }, + { + name: '{{agent}}', + content: { + text: "I'll load that workflow so we can modify it.", + actions: ['MODIFY_EXISTING_N8N_WORKFLOW'], + }, + }, + ], + [ + { + name: '{{user1}}', + content: { + text: 'Can you update the Slack alert automation?', + }, + }, + { + name: '{{agent}}', + content: { + text: 'Loading the Slack alert workflow for modification.', + actions: ['MODIFY_EXISTING_N8N_WORKFLOW'], + }, + }, + ], + [ + { + name: '{{user1}}', + content: { + text: 'Edit the workflow that sends reports to Proton', + }, + }, + { + name: '{{agent}}', + content: { + text: "I'll find and load that workflow so you can modify it.", + actions: ['MODIFY_EXISTING_N8N_WORKFLOW'], + }, + }, + ], + [ + { + name: '{{user1}}', + content: { + text: 'I want to change the payment processing workflow', + }, + }, + { + name: '{{agent}}', + content: { + text: 'Loading the payment workflow for editing.', + actions: ['MODIFY_EXISTING_N8N_WORKFLOW'], + }, + }, + ], +]; + +export const modifyExistingWorkflowAction: Action = { + name: 'MODIFY_EXISTING_N8N_WORKFLOW', + similes: [ + 'EDIT_EXISTING_WORKFLOW', + 'UPDATE_EXISTING_WORKFLOW', + 'CHANGE_EXISTING_WORKFLOW', + 'LOAD_WORKFLOW_FOR_EDIT', + ], + description: + 'Load an existing deployed n8n workflow for modification. ' + + 'Identifies workflows by name or semantic description and loads them into the draft editor. ' + + 'After loading, use CREATE_N8N_WORKFLOW to make changes, preview, and redeploy. ' + + 'Use this when the user wants to modify a workflow that is already deployed.', + + validate: async (runtime: IAgentRuntime): Promise => { + return !!runtime.getService(N8N_WORKFLOW_SERVICE_TYPE); + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + _options?: unknown, + callback?: HandlerCallback + ): Promise => { + const service = runtime.getService(N8N_WORKFLOW_SERVICE_TYPE); + + if (!service) { + logger.error( + { src: 'plugin:n8n-workflow:action:modify-existing' }, + 'N8n Workflow service not available' + ); + if (callback) { + const text = await formatActionResponse(runtime, 'ERROR', { + error: 'N8n Workflow service is not available. Check N8N_API_KEY and N8N_HOST.', + }); + await callback({ text, success: false }); + } + return { success: false }; + } + + try { + const userId = message.entityId; + const cacheKey = `workflow_draft:${userId}`; + + // Check for existing draft — if one exists, user should use CREATE_N8N_WORKFLOW + const existingDraft = await runtime.getCache(cacheKey); + if (existingDraft && Date.now() - existingDraft.createdAt < DRAFT_TTL_MS) { + if (callback) { + await callback({ + text: + 'You already have a workflow draft in progress. ' + + 'Please confirm, modify, or cancel that draft first before loading another workflow.', + success: false, + }); + } + return { success: false }; + } + + // List user's workflows + const workflows = await service.listWorkflows(userId); + + if (workflows.length === 0) { + if (callback) { + await callback({ + text: 'No deployed workflows found. Would you like to create a new one?', + success: false, + }); + } + return { success: false }; + } + + // Match user's description to a workflow + const context = buildConversationContext(message, state); + const matchResult = await matchWorkflow(runtime, context, workflows); + + logger.info( + { src: 'plugin:n8n-workflow:action:modify-existing' }, + `Workflow match: ${matchResult.matchedWorkflowId || 'none'} (confidence: ${matchResult.confidence})` + ); + + // No match or low confidence — show available workflows + if (!matchResult.matchedWorkflowId || matchResult.confidence === 'none') { + const workflowList = workflows + .map((wf) => `- "${wf.name}" (${wf.active ? 'active' : 'inactive'})`) + .join('\n'); + + if (callback) { + await callback({ + text: `I couldn't identify which workflow you want to modify. Here are your workflows:\n\n${workflowList}\n\nPlease specify which one you'd like to edit.`, + success: false, + }); + } + return { success: false }; + } + + // Low confidence — ask for confirmation + if (matchResult.confidence === 'low') { + const matchedWorkflow = workflows.find((wf) => wf.id === matchResult.matchedWorkflowId); + if (callback) { + await callback({ + text: `Did you mean the workflow "${matchedWorkflow?.name}"? Please confirm or be more specific.`, + success: false, + }); + } + return { success: false }; + } + + // Medium/High confidence — load the workflow + const workflowId = matchResult.matchedWorkflowId; + const fullWorkflow = await service.getWorkflow(workflowId); + + logger.info( + { src: 'plugin:n8n-workflow:action:modify-existing' }, + `Loading workflow "${fullWorkflow.name}" (${fullWorkflow.id}) with ${fullWorkflow.nodes?.length || 0} nodes` + ); + + // Create draft from the existing workflow + const draft: WorkflowDraft = { + workflow: fullWorkflow, + prompt: `Modify existing workflow: ${fullWorkflow.name}`, + userId, + createdAt: Date.now(), + }; + await runtime.setCache(cacheKey, draft); + + // Build preview data + const nodes = (fullWorkflow.nodes || []).map((n) => ({ + name: n.name, + type: n.type.replace('n8n-nodes-base.', ''), + })); + + const creds = new Set(); + for (const node of fullWorkflow.nodes || []) { + if (node.credentials) { + for (const c of Object.keys(node.credentials)) { + creds.add(c); + } + } + } + + const text = await formatActionResponse(runtime, 'WORKFLOW_LOADED', { + workflowName: fullWorkflow.name, + workflowId: fullWorkflow.id, + active: fullWorkflow.active, + nodes, + credentials: [...creds], + message: 'Workflow loaded for editing. Tell me what changes you want to make.', + }); + + if (callback) { + await callback({ text, success: true }); + } + + return { success: true, data: { workflowId, awaitingUserInput: true } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error( + { src: 'plugin:n8n-workflow:action:modify-existing' }, + `Failed to load workflow for modification: ${errorMessage}` + ); + + const text = await formatActionResponse(runtime, 'ERROR', { error: errorMessage }); + if (callback) { + await callback({ text, success: false }); + } + return { success: false }; + } + }, + + examples, +}; diff --git a/src/index.ts b/src/index.ts index 7fb806c..7f7e14e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { activateWorkflowAction, deactivateWorkflowAction, deleteWorkflowAction, + modifyExistingWorkflowAction, } from './actions/index'; import { workflowStatusProvider, @@ -58,6 +59,7 @@ export const n8nWorkflowPlugin: Plugin = { actions: [ createWorkflowAction, + modifyExistingWorkflowAction, getExecutionsAction, activateWorkflowAction, deactivateWorkflowAction, diff --git a/src/prompts/actionResponse.ts b/src/prompts/actionResponse.ts index 2275547..8d798f0 100644 --- a/src/prompts/actionResponse.ts +++ b/src/prompts/actionResponse.ts @@ -6,7 +6,7 @@ Rules: - Be concise — no filler Response types: -- PREVIEW: workflow name, node list (name + type), flow (→), credentials, assumptions. Mention it's a draft: user can confirm, modify, or cancel. If restoredAfterFailure is true, mention the new request failed and this is the previous draft. +- PREVIEW: workflow name, node list (name + type), flow (→), credentials, assumptions. If "changes" is present, list each changed parameter per node. Mention it's a draft: user can confirm, modify, or cancel. If restoredAfterFailure is true, mention the new request failed and this is the previous draft. - CLARIFICATION: list the questions, ask for details. - DEPLOY_SUCCESS: name, ID, node count, status. All credentials are resolved — workflow is ready. - AUTH_REQUIRED: list services + auth links (clickable). Ask user to connect then retry deploy. diff --git a/src/prompts/fieldCorrection.ts b/src/prompts/fieldCorrection.ts new file mode 100644 index 0000000..561b1e8 --- /dev/null +++ b/src/prompts/fieldCorrection.ts @@ -0,0 +1,18 @@ +export const FIELD_CORRECTION_SYSTEM_PROMPT = `Fix the n8n expression to use a valid field path. + +You will receive: +1. An expression with an invalid field reference +2. The available fields from the source node's output schema + +Return ONLY the corrected expression. No explanation. + +Example: +- Expression: {{ $json.sender }} +- Available: from.value[0].address, from.value[0].name, subject, id +- Output: {{ $json.from.value[0].address }}`; + +export const FIELD_CORRECTION_USER_PROMPT = `Expression: {expression} +Available fields: +{availableFields} + +Return the corrected expression:`; diff --git a/src/prompts/index.ts b/src/prompts/index.ts index 743c532..fc4a52d 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -3,3 +3,4 @@ export { WORKFLOW_GENERATION_SYSTEM_PROMPT } from './workflowGeneration'; export { DRAFT_INTENT_SYSTEM_PROMPT } from './draftIntent'; export { ACTION_RESPONSE_SYSTEM_PROMPT } from './actionResponse'; export { FEASIBILITY_CHECK_PROMPT } from './feasibilityCheck'; +export { FIELD_CORRECTION_SYSTEM_PROMPT, FIELD_CORRECTION_USER_PROMPT } from './fieldCorrection'; diff --git a/src/services/n8n-workflow-service.ts b/src/services/n8n-workflow-service.ts index db107f8..1a400f1 100644 --- a/src/services/n8n-workflow-service.ts +++ b/src/services/n8n-workflow-service.ts @@ -8,12 +8,14 @@ import { modifyWorkflow, collectExistingNodeDefinitions, assessFeasibility, + correctFieldReferences, } from '../utils/generation'; import { positionNodes, validateWorkflow, validateNodeParameters, validateNodeInputs, + validateOutputReferences, } from '../utils/workflow'; import { resolveCredentials } from '../utils/credentialResolver'; import type { @@ -228,7 +230,7 @@ export class N8nWorkflowService extends Service { } // ── End integration check ── - const workflow = await generateWorkflow( + let workflow = await generateWorkflow( this.runtime, prompt, relevantNodes.map((r) => r.node) @@ -238,6 +240,15 @@ export class N8nWorkflowService extends Service { `Generated workflow with ${workflow.nodes?.length || 0} nodes` ); + const invalidRefs = validateOutputReferences(workflow); + if (invalidRefs.length > 0) { + logger.debug( + { src: 'plugin:n8n-workflow:service:main' }, + `Found ${invalidRefs.length} invalid field reference(s), auto-correcting...` + ); + workflow = await correctFieldReferences(this.runtime, workflow, invalidRefs); + } + const validationResult = validateWorkflow(workflow); if (!validationResult.valid) { logger.error( @@ -289,13 +300,22 @@ export class N8nWorkflowService extends Service { `Modify context: ${existingDefs.length} existing + ${newDefs.length} searched → ${combinedDefs.length} unique node defs` ); - const workflow = await modifyWorkflow( + let workflow = await modifyWorkflow( this.runtime, existingWorkflow, modificationRequest, combinedDefs ); + const invalidRefs = validateOutputReferences(workflow); + if (invalidRefs.length > 0) { + logger.debug( + { src: 'plugin:n8n-workflow:service:main' }, + `Found ${invalidRefs.length} invalid field reference(s) in modified workflow, auto-correcting...` + ); + workflow = await correctFieldReferences(this.runtime, workflow, invalidRefs); + } + const validationResult = validateWorkflow(workflow); if (!validationResult.valid) { logger.error( @@ -325,13 +345,17 @@ export class N8nWorkflowService extends Service { const rawProvider = this.runtime.getService(N8N_CREDENTIAL_PROVIDER_TYPE); const credProvider = isCredentialProvider(rawProvider) ? rawProvider : null; + // Compute tag name once - reused for credentials and workflow tagging + const tagName = await getUserTagName(this.runtime, userId); + const credentialResult = await resolveCredentials( workflow, userId, config, credStore ?? null, credProvider, - client + client, + tagName ); // Block deploy if any credential is unresolved @@ -345,16 +369,25 @@ export class N8nWorkflowService extends Service { }; } - const createdWorkflow = await client.createWorkflow(credentialResult.workflow); + // Determine if this is an update (existing workflow) or create (new workflow) + const isUpdate = !!workflow.id; + const deployedWorkflow = isUpdate + ? await client.updateWorkflow(workflow.id!, credentialResult.workflow) + : await client.createWorkflow(credentialResult.workflow); - // Activate (publish) the workflow immediately after creation + logger.info( + { src: 'plugin:n8n-workflow:service:main' }, + `Workflow ${isUpdate ? 'updated' : 'created'}: ${deployedWorkflow.id}` + ); + + // Activate (publish) the workflow immediately after creation/update let active = false; try { - await client.activateWorkflow(createdWorkflow.id); + await client.activateWorkflow(deployedWorkflow.id); active = true; logger.info( { src: 'plugin:n8n-workflow:service:main' }, - `Workflow ${createdWorkflow.id} activated` + `Workflow ${deployedWorkflow.id} activated` ); } catch (error) { logger.warn( @@ -363,14 +396,14 @@ export class N8nWorkflowService extends Service { ); } - if (userId) { + // Only tag new workflows (existing ones should already have tags) + if (userId && !isUpdate) { try { - const tagName = await getUserTagName(this.runtime, userId); const userTag = await client.getOrCreateTag(tagName); - await client.updateWorkflowTags(createdWorkflow.id, [userTag.id]); + await client.updateWorkflowTags(deployedWorkflow.id, [userTag.id]); logger.debug( { src: 'plugin:n8n-workflow:service:main' }, - `Tagged workflow ${createdWorkflow.id} with "${tagName}"` + `Tagged workflow ${deployedWorkflow.id} with "${tagName}"` ); } catch (error) { logger.warn( @@ -380,16 +413,11 @@ export class N8nWorkflowService extends Service { } } - logger.info( - { src: 'plugin:n8n-workflow:service:main' }, - `Workflow created successfully: ${createdWorkflow.id}` - ); - return { - id: createdWorkflow.id, - name: createdWorkflow.name, + id: deployedWorkflow.id, + name: deployedWorkflow.name, active, - nodeCount: createdWorkflow.nodes?.length || 0, + nodeCount: deployedWorkflow.nodes?.length || 0, missingCredentials: credentialResult.missingConnections, }; } @@ -433,6 +461,11 @@ export class N8nWorkflowService extends Service { logger.info({ src: 'plugin:n8n-workflow:service:main' }, `Workflow ${workflowId} deleted`); } + async getWorkflow(workflowId: string): Promise { + const client = this.getClient(); + return client.getWorkflow(workflowId); + } + async getWorkflowExecutions(workflowId: string, limit?: number): Promise { const client = this.getClient(); const response = await client.listExecutions({ workflowId, limit }); diff --git a/src/types/index.ts b/src/types/index.ts index 6f502a0..aea8029 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -344,3 +344,39 @@ export class UnsupportedIntegrationError extends Error { this.name = 'UnsupportedIntegrationError'; } } + +// Output schema validation types + +/** + * JSON schema content from n8n output schemas + */ +export interface SchemaContent { + type: string; + properties?: Record; + items?: SchemaContent; + [key: string]: unknown; +} + +/** + * Expression reference found in node parameters + */ +export interface ExpressionRef { + fullExpression: string; // "{{ $json.sender }}" + field: string; // "sender" or "from.value[0].address" + path: string[]; // ["sender"] or ["from", "value", "0", "address"] + paramPath: string; // Parameter path where expression was found +} + +/** + * Invalid output reference detected during validation + */ +export interface OutputRefValidation { + nodeName: string; // Node with invalid reference + expression: string; // The full expression + field: string; // Referenced field + sourceNodeName: string; // Upstream node + sourceNodeType: string; // Upstream node type + resource: string; // Resource parameter + operation: string; // Operation parameter + availableFields: string[]; // Valid fields from schema +} diff --git a/src/utils/credentialResolver.ts b/src/utils/credentialResolver.ts index 0a68917..b34ba84 100644 --- a/src/utils/credentialResolver.ts +++ b/src/utils/credentialResolver.ts @@ -24,7 +24,8 @@ export async function resolveCredentials( config: N8nPluginConfig, credStore: N8nCredentialStoreApi | null, credProvider: CredentialProvider | null, - apiClient: N8nApiClient | null + apiClient: N8nApiClient | null, + tagName: string ): Promise { const requiredCredTypes = extractRequiredCredentialTypes(workflow); @@ -47,7 +48,8 @@ export async function resolveCredentials( credStore, credProvider, apiClient, - missingConnections + missingConnections, + tagName ); if (credId) { @@ -71,7 +73,8 @@ async function resolveOneCredential( credStore: N8nCredentialStoreApi | null, credProvider: CredentialProvider | null, apiClient: N8nApiClient | null, - missingConnections: MissingConnection[] + missingConnections: MissingConnection[], + tagName: string ): Promise { // 1. Credential store DB const cachedId = await credStore?.get(userId, credType); @@ -105,8 +108,9 @@ async function resolveOneCredential( missingConnections.push({ credType }); return null; } + const credName = `${credType}_${tagName}`; const n8nCred = await apiClient.createCredential({ - name: credType, + name: credName, type: credType, data: result.data, }); diff --git a/src/utils/generation.ts b/src/utils/generation.ts index 1189866..3a3c964 100644 --- a/src/utils/generation.ts +++ b/src/utils/generation.ts @@ -8,6 +8,7 @@ import { NodeDefinition, NodeSearchResult, FeasibilityResult, + OutputRefValidation, } from '../types/index'; import { KEYWORD_EXTRACTION_SYSTEM_PROMPT, @@ -15,6 +16,8 @@ import { DRAFT_INTENT_SYSTEM_PROMPT, ACTION_RESPONSE_SYSTEM_PROMPT, FEASIBILITY_CHECK_PROMPT, + FIELD_CORRECTION_SYSTEM_PROMPT, + FIELD_CORRECTION_USER_PROMPT, } from '../prompts/index'; import { WORKFLOW_MATCHING_SYSTEM_PROMPT } from '../prompts/workflowMatching'; import { @@ -193,10 +196,10 @@ ${userMessage}`, } function parseWorkflowResponse(response: string): N8nWorkflow { + // Strip markdown code fences (handles ```json, ```, with any whitespace/newlines) const cleaned = response - .replace(/^\s*```json\s*/i, '') - .replace(/^\s*```\s*/i, '') - .replace(/```\s*$/, '') + .replace(/^[\s\S]*?```(?:json)?\s*\n?/i, '') // Remove everything up to and including opening fence + .replace(/\n?```[\s\S]*$/i, '') // Remove closing fence and everything after .trim(); let workflow: N8nWorkflow; @@ -286,7 +289,14 @@ Keep all unchanged nodes and connections intact. Only add, remove, or change wha responseFormat: { type: 'json_object' }, }); - return parseWorkflowResponse(response); + const modified = parseWorkflowResponse(response); + + // Preserve the original workflow ID for updates (LLM doesn't return it) + if (existingWorkflow.id) { + modified.id = existingWorkflow.id; + } + + return modified; } export function collectExistingNodeDefinitions(workflow: N8nWorkflow): NodeDefinition[] { @@ -382,3 +392,94 @@ export async function assessFeasibility( }; } } + +/** + * Auto-corrects invalid field references in expressions using parallel LLM calls. + * Returns a new workflow with corrected expressions. + */ +export async function correctFieldReferences( + runtime: IAgentRuntime, + workflow: N8nWorkflow, + invalidRefs: OutputRefValidation[] +): Promise { + if (invalidRefs.length === 0) { + return workflow; + } + + logger.debug( + { src: 'plugin:n8n-workflow:generation:correction' }, + `Correcting ${invalidRefs.length} invalid field reference(s)` + ); + + const corrections = await Promise.all( + invalidRefs.map(async (ref) => { + try { + const userPrompt = FIELD_CORRECTION_USER_PROMPT.replace( + '{expression}', + ref.expression + ).replace('{availableFields}', ref.availableFields.join('\n')); + + const corrected = await runtime.useModel(ModelType.TEXT_SMALL, { + prompt: `${FIELD_CORRECTION_SYSTEM_PROMPT}\n\n${userPrompt}`, + temperature: 0, + }); + + const cleaned = (corrected as string).trim(); + return { original: ref.expression, corrected: cleaned, nodeName: ref.nodeName }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + logger.warn( + { src: 'plugin:n8n-workflow:generation:correction', error: errMsg }, + `Failed to correct expression "${ref.expression}": ${errMsg}` + ); + return null; + } + }) + ); + + const correctedWorkflow = JSON.parse(JSON.stringify(workflow)) as N8nWorkflow; + + for (const correction of corrections) { + if (!correction) { + continue; + } + + const node = correctedWorkflow.nodes.find((n) => n.name === correction.nodeName); + if (!node?.parameters) { + continue; + } + + replaceInObject(node.parameters, correction.original, correction.corrected); + + logger.debug( + { src: 'plugin:n8n-workflow:generation:correction' }, + `Corrected "${correction.original}" → "${correction.corrected}" in node "${correction.nodeName}"` + ); + } + + return correctedWorkflow; +} + +function replaceInObject( + obj: Record, + original: string, + replacement: string +): void { + for (const key of Object.keys(obj)) { + const value = obj[key]; + + if (typeof value === 'string' && value.includes(original)) { + obj[key] = value.replaceAll(original, replacement); + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (typeof value[i] === 'string' && value[i].includes(original)) { + value[i] = value[i].replaceAll(original, replacement); + } else if (typeof value[i] === 'object' && value[i] !== null) { + replaceInObject(value[i] as Record, original, replacement); + } + } + } else if (typeof value === 'object' && value !== null) { + replaceInObject(value as Record, original, replacement); + } + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index c910b88..5903e9a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,7 +11,21 @@ export { resolveCredentials } from './credentialResolver'; export { getUserTagName } from './context'; // Workflow generation pipeline -export { extractKeywords, matchWorkflow, generateWorkflow } from './generation'; +export { + extractKeywords, + matchWorkflow, + generateWorkflow, + correctFieldReferences, +} from './generation'; // Workflow validation & positioning -export { validateWorkflow, positionNodes } from './workflow'; +export { validateWorkflow, positionNodes, validateOutputReferences } from './workflow'; + +// Output schema utilities +export { + hasOutputSchema, + loadOutputSchema, + parseExpressions, + fieldExistsInSchema, + formatSchemaForPrompt, +} from './outputSchema'; diff --git a/src/utils/outputSchema.ts b/src/utils/outputSchema.ts new file mode 100644 index 0000000..c8b8f36 --- /dev/null +++ b/src/utils/outputSchema.ts @@ -0,0 +1,317 @@ +/** + * Output schema utilities for validating expressions between nodes. + * Uses pre-crawled schemaIndex.json with full schema content. + */ +import type { ExpressionRef, SchemaContent } from '../types/index'; +import schemaIndex from '../data/schemaIndex.json' assert { type: 'json' }; + +type SchemasByResource = Record>; + +interface SchemaEntry { + folder: string; + schemas: SchemasByResource; +} + +interface SchemaIndex { + nodeTypes: Record; + generatedAt: string; + version: string; +} + +const SCHEMA_INDEX = schemaIndex as unknown as SchemaIndex; + +export interface OutputSchemaResult { + schema: SchemaContent; + fields: string[]; +} + +export function hasOutputSchema(nodeType: string): boolean { + return nodeType in SCHEMA_INDEX.nodeTypes; +} + +export function getAvailableResources(nodeType: string): string[] { + const entry = SCHEMA_INDEX.nodeTypes[nodeType]; + if (!entry) { + return []; + } + return Object.keys(entry.schemas); +} + +export function getAvailableOperations(nodeType: string, resource: string): string[] { + const entry = SCHEMA_INDEX.nodeTypes[nodeType]; + if (!entry) { + return []; + } + const resourceSchemas = entry.schemas[resource]; + if (!resourceSchemas) { + return []; + } + return Object.keys(resourceSchemas); +} + +export function loadOutputSchema( + nodeType: string, + resource: string, + operation: string +): OutputSchemaResult | null { + const entry = SCHEMA_INDEX.nodeTypes[nodeType]; + if (!entry) { + return null; + } + + const resourceSchemas = entry.schemas[resource]; + if (!resourceSchemas) { + return null; + } + + const schema = resourceSchemas[operation]; + if (!schema) { + return null; + } + + return { + schema, + fields: getTopLevelFields(schema), + }; +} + +export function getTopLevelFields(schema: SchemaContent): string[] { + if (!schema.properties) { + return []; + } + return Object.keys(schema.properties); +} + +/** Returns all field paths including nested (e.g., "from.value[0].address") */ +export function getAllFieldPaths(schema: SchemaContent, prefix = ''): string[] { + const paths: string[] = []; + const properties = schema.properties; + + if (!properties) { + return paths; + } + + for (const [key, value] of Object.entries(properties)) { + const currentPath = prefix ? `${prefix}.${key}` : key; + paths.push(currentPath); + + if (typeof value === 'object' && value !== null) { + const propSchema = value as SchemaContent; + + if (propSchema.type === 'object' && propSchema.properties) { + paths.push(...getAllFieldPaths(propSchema, currentPath)); + } + + if (propSchema.type === 'array' && propSchema.items) { + const items = propSchema.items as SchemaContent; + if (items.type === 'object' && items.properties) { + paths.push(...getAllFieldPaths(items, `${currentPath}[0]`)); + } + } + } + } + + return paths; +} + +export function parseExpressions( + parameters: Record, + parentPath = '' +): ExpressionRef[] { + const refs: ExpressionRef[] = []; + + for (const [key, value] of Object.entries(parameters)) { + const currentPath = parentPath ? `${parentPath}.${key}` : key; + + if (typeof value === 'string') { + refs.push(...extractExpressionsFromString(value, currentPath)); + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (typeof value[i] === 'string') { + refs.push(...extractExpressionsFromString(value[i], `${currentPath}[${i}]`)); + } else if (typeof value[i] === 'object' && value[i] !== null) { + refs.push( + ...parseExpressions(value[i] as Record, `${currentPath}[${i}]`) + ); + } + } + } else if (typeof value === 'object' && value !== null) { + refs.push(...parseExpressions(value as Record, currentPath)); + } + } + + return refs; +} + +function extractExpressionsFromString(str: string, paramPath: string): ExpressionRef[] { + const refs: ExpressionRef[] = []; + + // Limit field path length to prevent ReDoS attacks + const simplePattern = /\{\{\s*\$json\.([a-zA-Z0-9_.\[\]'"-]{1,200})\s*\}\}/g; + const namedNodePattern = + /\{\{\s*\$\(['"]([^'"]{1,100})['"]\)\.item\.json\.([a-zA-Z0-9_.\[\]'"-]{1,200})\s*\}\}/g; + + let match; + + while ((match = simplePattern.exec(str)) !== null) { + const field = match[1]; + refs.push({ + fullExpression: match[0], + field, + path: parseFieldPath(field), + paramPath, + }); + } + + // Named node refs: $('Node Name').item.json.field (captures field part only for now) + while ((match = namedNodePattern.exec(str)) !== null) { + const field = match[2]; + refs.push({ + fullExpression: match[0], + field, + path: parseFieldPath(field), + paramPath, + }); + } + + return refs; +} + +/** + * Parses "from.value[0].address" or "headers['content-type']" into path segments. + */ +function parseFieldPath(field: string): string[] { + const path: string[] = []; + let current = ''; + let i = 0; + + while (i < field.length) { + const char = field[i]; + + if (char === '.') { + if (current) { + path.push(current); + current = ''; + } + i++; + } else if (char === '[') { + if (current) { + path.push(current); + current = ''; + } + i++; + if (i >= field.length) { + break; + } + if (field[i] === "'" || field[i] === '"') { + const quote = field[i]; + i++; + while (i < field.length && field[i] !== quote) { + current += field[i]; + i++; + } + i++; + } else { + while (i < field.length && field[i] !== ']') { + current += field[i]; + i++; + } + } + if (current) { + path.push(current); + current = ''; + } + i++; + } else { + current += char; + i++; + } + } + + if (current) { + path.push(current); + } + + return path; +} + +export function fieldExistsInSchema(path: string[], schema: SchemaContent): boolean { + if (path.length === 0) { + return false; + } + + let current: SchemaContent | undefined = schema; + + for (let i = 0; i < path.length; i++) { + const segment = path[i]; + + if (!current || typeof current !== 'object') { + return false; + } + + const properties = current.properties; + if (!properties) { + return false; + } + + const prop = properties[segment] as SchemaContent | undefined; + if (!prop) { + return false; + } + + if (i === path.length - 1) { + return true; + } + + if (prop.type === 'object') { + current = prop; + } else if (prop.type === 'array' && prop.items) { + const nextSegment = path[i + 1]; + if (/^\d+$/.test(nextSegment)) { + i++; + current = prop.items as SchemaContent; + } else { + return false; + } + } else { + return false; + } + } + + return false; +} + +export function formatSchemaForPrompt(schema: SchemaContent, maxDepth = 2): string { + const lines: string[] = []; + + function format(obj: SchemaContent, depth: number, prefix: string) { + const properties = obj.properties; + if (!properties || depth > maxDepth) { + return; + } + + for (const [key, value] of Object.entries(properties)) { + const prop = value as SchemaContent; + const type = prop.type as string; + const path = prefix ? `${prefix}.${key}` : key; + + if (type === 'object' && prop.properties) { + lines.push(`${path}: object`); + format(prop, depth + 1, path); + } else if (type === 'array' && prop.items) { + const items = prop.items as SchemaContent; + if (items.type === 'object' && items.properties) { + lines.push(`${path}: array of objects`); + format(items, depth + 1, `${path}[0]`); + } else { + lines.push(`${path}: array of ${items.type || 'unknown'}`); + } + } else { + lines.push(`${path}: ${type || 'unknown'}`); + } + } + } + + format(schema, 0, ''); + return lines.join('\n'); +} diff --git a/src/utils/workflow.ts b/src/utils/workflow.ts index 7ee428c..da41081 100644 --- a/src/utils/workflow.ts +++ b/src/utils/workflow.ts @@ -1,5 +1,16 @@ -import type { N8nWorkflow, NodeProperty, WorkflowValidationResult } from '../types/index'; +import type { + N8nWorkflow, + NodeProperty, + WorkflowValidationResult, + OutputRefValidation, +} from '../types/index'; import { getNodeDefinition } from './catalog'; +import { + loadOutputSchema, + parseExpressions, + fieldExistsInSchema, + getAllFieldPaths, +} from './outputSchema'; export function validateWorkflow(workflow: N8nWorkflow): WorkflowValidationResult { const errors: string[] = []; @@ -283,6 +294,88 @@ export function positionNodes(workflow: N8nWorkflow): N8nWorkflow { return positioned; } +/** + * Validates that $json expressions reference fields that exist in upstream node output schemas. + * Returns a list of invalid references that need correction. + */ +export function validateOutputReferences(workflow: N8nWorkflow): OutputRefValidation[] { + const invalidRefs: OutputRefValidation[] = []; + const upstreamMap = buildUpstreamMap(workflow); + const nodeMap = new Map(workflow.nodes.map((n) => [n.name, n])); + + for (const node of workflow.nodes) { + if (!node.parameters) { + continue; + } + + const expressions = parseExpressions(node.parameters); + if (expressions.length === 0) { + continue; + } + + const upstreamNames = upstreamMap.get(node.name) || []; + if (upstreamNames.length === 0) { + continue; + } + + // TODO: Handle $('NodeName').item.json refs to specific nodes + const sourceNodeName = upstreamNames[0]; + const sourceNode = nodeMap.get(sourceNodeName); + if (!sourceNode) { + continue; + } + + const resource = (sourceNode.parameters?.resource as string) || ''; + const operation = (sourceNode.parameters?.operation as string) || ''; + const schemaResult = loadOutputSchema(sourceNode.type, resource, operation); + if (!schemaResult) { + continue; + } + + for (const expr of expressions) { + const exists = fieldExistsInSchema(expr.path, schemaResult.schema); + if (!exists) { + invalidRefs.push({ + nodeName: node.name, + expression: expr.fullExpression, + field: expr.field, + sourceNodeName, + sourceNodeType: sourceNode.type, + resource, + operation, + availableFields: getAllFieldPaths(schemaResult.schema), + }); + } + } + } + + return invalidRefs; +} + +function buildUpstreamMap(workflow: N8nWorkflow): Map { + const upstream = new Map(); + + for (const node of workflow.nodes) { + upstream.set(node.name, []); + } + + for (const [sourceName, outputs] of Object.entries(workflow.connections)) { + for (const connectionGroups of Object.values(outputs)) { + for (const connections of connectionGroups) { + for (const conn of connections) { + const existing = upstream.get(conn.node) || []; + if (!existing.includes(sourceName)) { + existing.push(sourceName); + upstream.set(conn.node, existing); + } + } + } + } + } + + return upstream; +} + function buildNodeGraph(workflow: N8nWorkflow): Map { const graph = new Map(); From 2ccdb75bc9209bcd3fe4e723ea0b112edc8ae0b0 Mon Sep 17 00:00:00 2001 From: standujar Date: Mon, 9 Feb 2026 14:13:06 +0100 Subject: [PATCH 2/9] feat: trigger schema validation, field correction, and draft lifecycle hardening - Add trigger output schema capture and validation (Gmail, GitHub, Google Calendar, etc.) - Fix parseExpressions regex to detect $json refs in compound expressions (||, ternary) - Include field types in correction prompt so LLM picks string over object - Force simple=true on trigger nodes that support it (normalizeTriggerSimpleParam) - Skip schema validation when simple=false (raw mode differs from captured schema) - Remove explicit intent parameter to prevent multi-step agent auto-confirm - Add originMessageId guard to block same-turn draft confirmation - Remove duplicate intent classification log - Add schema-update CI workflow (weekly cron, auto-bump on schema change) - Update npm-deploy workflow with crawl + trigger capture steps --- .github/workflows/npm-deploy.yml | 10 +- .github/workflows/schema-update.yml | 82 ++ .gitignore | 4 + __tests__/fixtures/workflows.ts | 22 + .../actions/createWorkflow.test.ts | 235 ------ __tests__/unit/outputSchema.test.ts | 74 ++ __tests__/unit/workflow.test.ts | 225 ++++- scripts/capture-trigger-schemas.ts | 553 +++++++++++++ scripts/crawl-trigger-schemas.ts | 771 ++++++++++++++++++ scripts/create-credentials.ts | 280 +++++++ scripts/trigger-oauth2-status.md | 65 ++ src/actions/createWorkflow.ts | 67 +- src/prompts/fieldCorrection.ts | 16 +- src/services/n8n-workflow-service.ts | 3 + src/types/index.ts | 2 + src/utils/generation.ts | 5 - src/utils/outputSchema.ts | 48 +- src/utils/workflow.ts | 56 +- 18 files changed, 2195 insertions(+), 323 deletions(-) create mode 100644 .github/workflows/schema-update.yml create mode 100644 scripts/capture-trigger-schemas.ts create mode 100644 scripts/crawl-trigger-schemas.ts create mode 100644 scripts/create-credentials.ts create mode 100644 scripts/trigger-oauth2-status.md diff --git a/.github/workflows/npm-deploy.yml b/.github/workflows/npm-deploy.yml index d736098..04c49f2 100644 --- a/.github/workflows/npm-deploy.yml +++ b/.github/workflows/npm-deploy.yml @@ -70,8 +70,14 @@ jobs: - name: Install dependencies run: bun install - - name: Generate node catalog - run: bun run crawl-nodes + - name: Generate node catalog and schemas + run: bun run crawl + + - name: Capture trigger schemas + run: bun run scripts/capture-trigger-schemas.ts --from-existing + env: + N8N_API_KEY: ${{ secrets.N8N_API_KEY }} + N8N_HOST: ${{ secrets.N8N_HOST }} - name: Build package run: bun run build diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml new file mode 100644 index 0000000..6f5e661 --- /dev/null +++ b/.github/workflows/schema-update.yml @@ -0,0 +1,82 @@ +name: Schema Update + +on: + schedule: + - cron: '0 3 * * 1' # Monday 3AM UTC + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + + - name: Generate schemas + run: bun run crawl + + - name: Capture trigger schemas + run: bun run scripts/capture-trigger-schemas.ts --from-existing + env: + N8N_API_KEY: ${{ secrets.N8N_API_KEY }} + N8N_HOST: ${{ secrets.N8N_HOST }} + + - run: bun run build + + - name: Download latest release + run: | + mkdir -p /tmp/latest + bunx npm pack @elizaos/plugin-n8n-workflow@latest --pack-destination /tmp + tar xzf /tmp/elizaos-plugin-n8n-workflow-*.tgz -C /tmp/latest + + - name: Compare with latest release + id: compare + run: | + CHANGED=false + + for file in defaultNodes.json schemaIndex.json triggerSchemaIndex.json; do + NEW="dist/data/$file" + OLD="/tmp/latest/package/dist/data/$file" + + if [ ! -f "$NEW" ]; then + continue + fi + + if [ ! -f "$OLD" ]; then + echo "$file: NEW file (not in latest release)" + CHANGED=true + continue + fi + + if ! diff <(jq -S . "$NEW") <(jq -S . "$OLD") > /dev/null 2>&1; then + echo "$file: CHANGED" + CHANGED=true + else + echo "$file: unchanged" + fi + done + + echo "changed=$CHANGED" >> $GITHUB_OUTPUT + + - name: Bump version + if: steps.compare.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + npm version patch --no-git-tag-version + VERSION=$(jq -r .version package.json) + git add package.json + git commit -m "chore: bump to v${VERSION} (schema update)" + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Trigger publish + if: steps.compare.outputs.changed == 'true' + run: gh workflow run npm-deploy.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 26e5e6c..080946f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,8 @@ temp/ # Generated data files (run `bun run crawl` to regenerate) src/data/defaultNodes.json src/data/schemaIndex.json +src/data/triggerSchemaIndex.json + +# Script cache +.cache/ diff --git a/__tests__/fixtures/workflows.ts b/__tests__/fixtures/workflows.ts index 5c684a7..099780a 100644 --- a/__tests__/fixtures/workflows.ts +++ b/__tests__/fixtures/workflows.ts @@ -62,6 +62,28 @@ export function createSlackNode(overrides?: Partial): N8nNode { }; } +export function createGmailTriggerNode(overrides?: Partial): N8nNode { + return { + name: 'Gmail Trigger', + type: 'n8n-nodes-base.gmailTrigger', + typeVersion: 1, + position: [250, 300], + parameters: {}, + ...overrides, + }; +} + +export function createGithubTriggerNode(overrides?: Partial): N8nNode { + return { + name: 'GitHub Trigger', + type: 'n8n-nodes-base.githubTrigger', + typeVersion: 1, + position: [250, 300], + parameters: {}, + ...overrides, + }; +} + // ============================================================================ // WORKFLOWS // ============================================================================ diff --git a/__tests__/integration/actions/createWorkflow.test.ts b/__tests__/integration/actions/createWorkflow.test.ts index 8d1a6e0..2bfcfb9 100644 --- a/__tests__/integration/actions/createWorkflow.test.ts +++ b/__tests__/integration/actions/createWorkflow.test.ts @@ -513,241 +513,6 @@ describe('CREATE_N8N_WORKFLOW action', () => { }); }); - // ========================================================================== - // EXPLICIT INTENT VIA _OPTIONS (multi-step agent support) - // ========================================================================== - - describe('handler - explicit intent via _options', () => { - function createDraftInCache(): WorkflowDraft { - return { - workflow: { - name: 'Stripe Gmail Summary', - nodes: [ - { - name: 'Schedule Trigger', - type: 'n8n-nodes-base.scheduleTrigger', - typeVersion: 1, - position: [0, 0] as [number, number], - parameters: {}, - }, - { - name: 'Gmail', - type: 'n8n-nodes-base.gmail', - typeVersion: 2, - position: [200, 0] as [number, number], - parameters: { operation: 'send' }, - credentials: { - gmailOAuth2Api: { id: '{{CREDENTIAL_ID}}', name: 'Gmail Account' }, - }, - }, - ], - connections: { - 'Schedule Trigger': { - main: [[{ node: 'Gmail', type: 'main', index: 0 }]], - }, - }, - }, - prompt: 'Send Stripe summaries via Gmail', - userId: 'user-001', - createdAt: Date.now(), - }; - } - - test('explicit intent=confirm bypasses LLM classification and deploys', async () => { - const draft = createDraftInCache(); - const mockService = createMockService(); - - // useModel returns "modify" — but should be IGNORED when explicit intent is passed - const runtime = createMockRuntime({ - services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, - useModel: createUseModelMock({ intent: 'modify', reason: 'LLM says modify' }), - cache: { 'workflow_draft:user-001': draft }, - }); - - const message = createMockMessage({ - content: { text: 'modifie le workflow pour utiliser outlook' }, // Text says modify - }); - const callback = createMockCallback(); - - // Pass explicit intent=confirm via _options - const result = await createWorkflowAction.handler( - runtime, - message, - createMockState(), - { intent: 'confirm' }, // <-- explicit intent - callback - ); - - expect(result?.success).toBe(true); - // Should deploy, not modify - expect(mockService.deployWorkflow).toHaveBeenCalledTimes(1); - expect(mockService.modifyWorkflowDraft).not.toHaveBeenCalled(); - expect(runtime.deleteCache).toHaveBeenCalled(); - }); - - test('explicit intent=cancel bypasses LLM classification and cancels', async () => { - const draft = createDraftInCache(); - const mockService = createMockService(); - - // useModel returns "confirm" — but should be IGNORED - const runtime = createMockRuntime({ - services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, - useModel: createUseModelMock({ intent: 'confirm', reason: 'LLM says confirm' }), - cache: { 'workflow_draft:user-001': draft }, - }); - - const message = createMockMessage({ - content: { text: 'yes deploy it' }, // Text says confirm - }); - const callback = createMockCallback(); - - // Pass explicit intent=cancel via _options - const result = await createWorkflowAction.handler( - runtime, - message, - createMockState(), - { intent: 'cancel' }, // <-- explicit intent - callback - ); - - expect(result?.success).toBe(true); - // Should cancel, not deploy - expect(mockService.deployWorkflow).not.toHaveBeenCalled(); - expect(runtime.deleteCache).toHaveBeenCalled(); - - const calls = (callback as any).mock.calls; - const lastText = calls[calls.length - 1][0].text; - expect(lastText).toContain('Stripe Gmail Summary'); // cancelled workflow name - }); - - test('explicit intent=modify with modification bypasses LLM classification', async () => { - const draft = createDraftInCache(); - const mockService = createMockService(); - - // useModel returns "confirm" — but should be IGNORED - const runtime = createMockRuntime({ - services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, - useModel: createUseModelMock({ intent: 'confirm', reason: 'LLM says confirm' }), - cache: { 'workflow_draft:user-001': draft }, - }); - - const message = createMockMessage({ - content: { text: 'ok' }, // Ambiguous text - }); - const callback = createMockCallback(); - - // Pass explicit intent=modify with modification via _options - const result = await createWorkflowAction.handler( - runtime, - message, - createMockState(), - { intent: 'modify', modification: 'Use Outlook instead of Gmail' }, // <-- explicit - callback - ); - - expect(result?.success).toBe(true); - expect(result?.data).toEqual({ awaitingUserInput: true }); - // Should modify, not deploy - expect(mockService.modifyWorkflowDraft).toHaveBeenCalledTimes(1); - expect(mockService.deployWorkflow).not.toHaveBeenCalled(); - - // Should use the modification from _options, not the message text - const modifyCall = (mockService.modifyWorkflowDraft as any).mock.calls[0]; - expect(modifyCall[1]).toBe('Use Outlook instead of Gmail'); - }); - - test('explicit intent=new bypasses LLM classification and generates fresh', async () => { - const draft = createDraftInCache(); - const mockService = createMockService(); - - // useModel returns "confirm" — but should be IGNORED - const runtime = createMockRuntime({ - services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, - useModel: createUseModelMock({ intent: 'confirm', reason: 'LLM says confirm' }), - cache: { 'workflow_draft:user-001': draft }, - }); - - const message = createMockMessage({ - content: { text: 'Create a Slack notification workflow' }, - }); - const callback = createMockCallback(); - - // Pass explicit intent=new via _options - const result = await createWorkflowAction.handler( - runtime, - message, - createMockState(), - { intent: 'new' }, // <-- explicit intent - callback - ); - - expect(result?.success).toBe(true); - // Should generate new workflow, not deploy existing draft - expect(mockService.generateWorkflowDraft).toHaveBeenCalledTimes(1); - expect(mockService.deployWorkflow).not.toHaveBeenCalled(); - expect(runtime.deleteCache).toHaveBeenCalled(); // Clears old draft before generating - }); - - test('invalid explicit intent falls back to LLM classification', async () => { - const draft = createDraftInCache(); - const mockService = createMockService(); - - // useModel returns "cancel" - const runtime = createMockRuntime({ - services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, - useModel: createUseModelMock({ intent: 'cancel', reason: 'LLM says cancel' }), - cache: { 'workflow_draft:user-001': draft }, - }); - - const message = createMockMessage({ - content: { text: 'cancel it' }, - }); - const callback = createMockCallback(); - - // Pass invalid intent via _options — should be ignored - const result = await createWorkflowAction.handler( - runtime, - message, - createMockState(), - { intent: 'invalid_intent' }, // <-- invalid, falls back to LLM - callback - ); - - expect(result?.success).toBe(true); - // Should use LLM result (cancel) - expect(mockService.deployWorkflow).not.toHaveBeenCalled(); - expect(runtime.deleteCache).toHaveBeenCalled(); - }); - - test('no _options falls back to LLM classification', async () => { - const draft = createDraftInCache(); - const mockService = createMockService(); - - const runtime = createMockRuntime({ - services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, - useModel: createUseModelMock({ intent: 'confirm', reason: 'User agreed' }), - cache: { 'workflow_draft:user-001': draft }, - }); - - const message = createMockMessage({ - content: { text: 'yes deploy' }, - }); - const callback = createMockCallback(); - - // No _options passed — uses LLM - const result = await createWorkflowAction.handler( - runtime, - message, - createMockState(), - undefined, // <-- no options - callback - ); - - expect(result?.success).toBe(true); - expect(mockService.deployWorkflow).toHaveBeenCalledTimes(1); - }); - }); - // ========================================================================== // MODIFY INCLUDES CHANGES IN PREVIEW // ========================================================================== diff --git a/__tests__/unit/outputSchema.test.ts b/__tests__/unit/outputSchema.test.ts index b855dac..18bea4e 100644 --- a/__tests__/unit/outputSchema.test.ts +++ b/__tests__/unit/outputSchema.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from 'bun:test'; import { hasOutputSchema, loadOutputSchema, + loadTriggerOutputSchema, getTopLevelFields, getAllFieldPaths, parseExpressions, @@ -141,6 +142,24 @@ describe('parseExpressions', () => { expect(refs[0].field).toBe('subject'); }); + test('extracts both refs from compound expression with ||', () => { + const params = { body: '{{ $json.textHtml || $json.textPlain }}' }; + const refs = parseExpressions(params); + expect(refs).toHaveLength(2); + expect(refs[0].field).toBe('textHtml'); + expect(refs[1].field).toBe('textPlain'); + expect(refs[0].fullExpression).toBe('$json.textHtml'); + expect(refs[1].fullExpression).toBe('$json.textPlain'); + }); + + test('extracts ref from ternary expression', () => { + const params = { value: '{{ $json.name ? $json.name : "default" }}' }; + const refs = parseExpressions(params); + expect(refs).toHaveLength(2); + expect(refs[0].field).toBe('name'); + expect(refs[1].field).toBe('name'); + }); + test('returns empty array for no expressions', () => { const params = { message: 'Hello world' }; const refs = parseExpressions(params); @@ -191,3 +210,58 @@ describe('formatSchemaForPrompt', () => { expect(formatted).not.toContain('from.value[0].address'); }); }); + +describe('loadTriggerOutputSchema', () => { + test('loads Gmail trigger schema', () => { + const result = loadTriggerOutputSchema('n8n-nodes-base.gmailTrigger'); + expect(result).not.toBeNull(); + expect(result!.fields).toContain('Subject'); + expect(result!.fields).toContain('From'); + expect(result!.fields).toContain('To'); + expect(result!.fields).toContain('id'); + }); + + test('loads GitHub trigger schema with nested body', () => { + const result = loadTriggerOutputSchema('n8n-nodes-base.githubTrigger'); + expect(result).not.toBeNull(); + expect(result!.fields).toContain('body'); + expect(result!.fields).toContain('headers'); + // Nested field check via schema + expect(fieldExistsInSchema(['body', 'repository', 'name'], result!.schema)).toBe(true); + }); + + test('loads Google Calendar trigger schema', () => { + const result = loadTriggerOutputSchema('n8n-nodes-base.googleCalendarTrigger'); + expect(result).not.toBeNull(); + expect(result!.fields).toContain('summary'); + expect(result!.fields).toContain('start'); + expect(result!.fields).toContain('end'); + }); + + test('returns null for unknown trigger', () => { + const result = loadTriggerOutputSchema('n8n-nodes-base.unknownTrigger'); + expect(result).toBeNull(); + }); + + test('returns null for empty schema (Google Sheets)', () => { + const result = loadTriggerOutputSchema('n8n-nodes-base.googleSheetsTrigger'); + // Sheets had an empty execution — no properties + expect(result).toBeNull(); + }); + + test('returns null when simple=false (raw mode differs from captured schema)', () => { + const result = loadTriggerOutputSchema('n8n-nodes-base.gmailTrigger', { simple: false }); + expect(result).toBeNull(); + }); + + test('loads schema when simple=true (matches captured schema)', () => { + const result = loadTriggerOutputSchema('n8n-nodes-base.gmailTrigger', { simple: true }); + expect(result).not.toBeNull(); + expect(result!.fields).toContain('Subject'); + }); + + test('loads schema when no parameters (default is simple=true)', () => { + const result = loadTriggerOutputSchema('n8n-nodes-base.gmailTrigger'); + expect(result).not.toBeNull(); + }); +}); diff --git a/__tests__/unit/workflow.test.ts b/__tests__/unit/workflow.test.ts index a27c128..12ec5a7 100644 --- a/__tests__/unit/workflow.test.ts +++ b/__tests__/unit/workflow.test.ts @@ -1,5 +1,11 @@ import { describe, test, expect } from 'bun:test'; -import { validateWorkflow, positionNodes } from '../../src/utils/workflow'; +import { + validateWorkflow, + positionNodes, + validateOutputReferences, + validateNodeParameters, + validateNodeInputs, +} from '../../src/utils/workflow'; import { createValidWorkflow, createWorkflowWithoutPositions, @@ -9,6 +15,8 @@ import { createInvalidWorkflow_duplicateNames, createTriggerNode, createGmailNode, + createGmailTriggerNode, + createGithubTriggerNode, createSlackNode, } from '../fixtures/workflows'; @@ -246,3 +254,218 @@ describe('positionNodes', () => { } }); }); + +// ============================================================================ +// validateOutputReferences +// ============================================================================ + +describe('validateOutputReferences', () => { + test('valid trigger field passes (Gmail Subject)', () => { + const workflow = { + name: 'Gmail to Slack', + nodes: [ + createGmailTriggerNode(), + createSlackNode({ parameters: { text: '={{ $json.Subject }}' } }), + ], + connections: { + 'Gmail Trigger': { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + expect(refs).toEqual([]); + }); + + test('detects wrong case on trigger field (subject vs Subject)', () => { + const workflow = { + name: 'Gmail to Slack', + nodes: [ + createGmailTriggerNode(), + createSlackNode({ parameters: { text: '={{ $json.subject }}' } }), + ], + connections: { + 'Gmail Trigger': { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + expect(refs.length).toBe(1); + expect(refs[0].field).toBe('subject'); + expect(refs[0].sourceNodeType).toBe('n8n-nodes-base.gmailTrigger'); + expect(refs[0].availableFields).toContain('Subject (string)'); + }); + + test('valid nested trigger field (GitHub body.repository.name)', () => { + const workflow = { + name: 'GitHub to Slack', + nodes: [ + createGithubTriggerNode(), + createSlackNode({ parameters: { text: '={{ $json.body.repository.name }}' } }), + ], + connections: { + 'GitHub Trigger': { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + expect(refs).toEqual([]); + }); + + test('detects invalid nested trigger field', () => { + const workflow = { + name: 'GitHub to Slack', + nodes: [ + createGithubTriggerNode(), + createSlackNode({ parameters: { text: '={{ $json.body.repo }}' } }), + ], + connections: { + 'GitHub Trigger': { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + expect(refs.length).toBe(1); + expect(refs[0].field).toBe('body.repo'); + }); + + test('skips unknown trigger type (no false positives)', () => { + const workflow = { + name: 'Unknown trigger', + nodes: [ + createTriggerNode({ name: 'My Trigger', type: 'n8n-nodes-base.unknownTrigger' }), + createSlackNode({ parameters: { text: '={{ $json.anything }}' } }), + ], + connections: { + 'My Trigger': { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + expect(refs).toEqual([]); + }); + + test('validates non-trigger node (Gmail resource/operation schema)', () => { + const workflow = { + name: 'Gmail getAll to Slack', + nodes: [ + createTriggerNode(), + createGmailNode({ + parameters: { resource: 'message', operation: 'getAll' }, + }), + createSlackNode({ parameters: { text: '={{ $json.subject }}' } }), + ], + connections: { + 'Schedule Trigger': { + main: [[{ node: 'Gmail', type: 'main', index: 0 }]], + }, + Gmail: { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + expect(refs).toEqual([]); + }); + + test('mixed valid and invalid expressions', () => { + const workflow = { + name: 'Gmail to Slack', + nodes: [ + createGmailTriggerNode(), + createSlackNode({ + parameters: { text: '={{ $json.Subject }} from {{ $json.sender }}' }, + }), + ], + connections: { + 'Gmail Trigger': { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + expect(refs.length).toBe(1); + expect(refs[0].field).toBe('sender'); + }); + + test('no expressions returns empty', () => { + const workflow = { + name: 'Static workflow', + nodes: [createGmailTriggerNode(), createSlackNode({ parameters: { text: 'Hello world' } })], + connections: { + 'Gmail Trigger': { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + expect(refs).toEqual([]); + }); +}); + +// ============================================================================ +// validateNodeParameters +// ============================================================================ + +describe('validateNodeParameters', () => { + test('detects missing required parameters', () => { + // Default Gmail fixture is missing required "Email Type" parameter + const warnings = validateNodeParameters(createValidWorkflow()); + expect(warnings.some((w) => w.includes('Gmail') && w.includes('required parameter'))).toBe( + true + ); + }); + + test('skips unknown node types', () => { + const workflow = { + name: 'Unknown', + nodes: [ + { + name: 'Custom', + type: 'n8n-nodes-community.unknownNode', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: {}, + }, + ], + connections: {}, + }; + const warnings = validateNodeParameters(workflow); + expect(warnings).toEqual([]); + }); +}); + +// ============================================================================ +// validateNodeInputs +// ============================================================================ + +describe('validateNodeInputs', () => { + test('returns no warnings for properly connected workflow', () => { + const warnings = validateNodeInputs(createValidWorkflow()); + expect(warnings).toEqual([]); + }); + + test('warns about action node with no incoming connection', () => { + const workflow = { + name: 'Disconnected', + nodes: [createTriggerNode(), createGmailNode()], + connections: {}, + }; + const warnings = validateNodeInputs(workflow); + expect(warnings.some((w) => w.includes('Gmail'))).toBe(true); + }); + + test('does not warn about trigger nodes without incoming connections', () => { + const workflow = { + name: 'Only trigger', + nodes: [createTriggerNode()], + connections: {}, + }; + const warnings = validateNodeInputs(workflow); + expect(warnings).toEqual([]); + }); +}); diff --git a/scripts/capture-trigger-schemas.ts b/scripts/capture-trigger-schemas.ts new file mode 100644 index 0000000..97fd6e7 --- /dev/null +++ b/scripts/capture-trigger-schemas.ts @@ -0,0 +1,553 @@ +#!/usr/bin/env npx ts-node +/** + * Capture trigger output schemas by executing real workflows. + * + * Uses defaultNodes.json (the plugin's node catalog) to build test workflows — + * no regex parsing of node source files needed. + * + * Flow: + * 1. Read trigger definitions from defaultNodes.json + * 2. Match triggers with available credentials + * 3. For each match: create workflow (trigger → NoOp), activate, wait, capture + * 4. Save schemas to triggerSchemaIndex.json + * + * Usage: + * N8N_HOST=http://localhost:5678 N8N_API_KEY=xxx bun run scripts/capture-trigger-schemas.ts + * + * Options: + * --trigger=gmail Only capture triggers matching this name + * --timeout=60 Max seconds to wait per trigger (default: 30) + * --keep Don't delete test workflows after capture + * --create-only Create workflows but don't activate (for manual debug) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const N8N_HOST = process.env.N8N_HOST; +const N8N_API_KEY = process.env.N8N_API_KEY; + +if (!N8N_HOST || !N8N_API_KEY) { + console.error('Missing N8N_HOST or N8N_API_KEY environment variables'); + process.exit(1); +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface SchemaProperty { + type: string; + properties?: Record; + items?: SchemaProperty; +} + +interface TriggerOutputSchema { + type: 'object'; + properties: Record; +} + +interface TriggerSchemaEntry { + outputSchema: TriggerOutputSchema; +} + +interface TriggerSchemaIndex { + version: string; + generatedAt: string; + source: 'execution'; + triggers: Record; + stats: { + total: number; + captured: number; + failed: number; + skipped: number; + }; +} + +interface NodeDef { + name: string; + displayName: string; + group: string[]; + version: number | number[]; + properties: Array<{ + name: string; + type: string; + default: unknown; + required?: boolean; + options?: Array<{ name: string; value: unknown }>; + displayOptions?: { show?: Record; hide?: Record }; + }>; + credentials?: Array<{ + name: string; + required: boolean; + displayOptions?: { show?: Record }; + }>; + webhooks?: unknown[]; + polling?: boolean; +} + +interface N8nExecution { + id: string; + finished: boolean; + mode: string; + status: string; + startedAt: string; + stoppedAt?: string; + workflowId: string; + data?: { + resultData?: { + runData?: Record< + string, + Array<{ data: { main: Array }>> } }> + >; + }; + }; +} + +// ─── n8n API ───────────────────────────────────────────────────────────────── + +async function n8nRequest(method: string, endpoint: string, body?: unknown): Promise { + const url = `${N8N_HOST}/api/v1${endpoint}`; + const options: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + 'X-N8N-API-KEY': N8N_API_KEY!, + }, + }; + if (body) options.body = JSON.stringify(body); + + const response = await fetch(url, options); + if (response.status === 204) return undefined as unknown as T; + if (!response.ok) { + const text = await response.text(); + throw new Error(`n8n ${method} ${endpoint}: ${response.status} ${text}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : (undefined as unknown as T); +} + +// ─── Known credentials ────────────────────────────────────────────────────── +// n8n cloud doesn't support GET /credentials — use this map. + +const KNOWN_CREDENTIALS: Record = { + gmailOAuth2: 'hjSOdmEwxXzQpj3V', + googleCalendarOAuth2Api: 'yHjZgYhYPxTOFwdr', + googleDriveOAuth2Api: 'LoKYrCVJOpU8PhAM', + googleSheetsTriggerOAuth2Api: 'UcHkFKN0Khm7z1O3', + googleBusinessProfileOAuth2Api: 'lo6OS1caDfS84dgR', + githubOAuth2Api: 'T3JHHfCQmwbQgtEr', + linearOAuth2Api: 'jADHC6LxJ8ax7JMc', +}; + +// ─── Trigger discovery from defaultNodes.json ──────────────────────────────── + +interface TriggerInfo { + nodeType: string; + displayName: string; + triggerType: 'webhook' | 'polling' | 'unknown'; + credentialTypes: string[]; + typeVersion: number; + nodeDef: NodeDef; +} + +function discoverTriggers(): TriggerInfo[] { + const catalogPath = path.join(__dirname, '../src/data/defaultNodes.json'); + const nodes: NodeDef[] = JSON.parse(fs.readFileSync(catalogPath, 'utf-8')); + + return nodes + .filter((n) => n.group?.includes('trigger')) + .map((n) => { + const triggerType: TriggerInfo['triggerType'] = n.webhooks?.length + ? 'webhook' + : n.polling + ? 'polling' + : 'unknown'; + + const version = Array.isArray(n.version) ? Math.max(...n.version) : n.version; + + const credentialTypes = (n.credentials || []).map((c) => c.name); + + return { + nodeType: `n8n-nodes-base.${n.name}`, + displayName: n.displayName, + triggerType, + credentialTypes, + typeVersion: version, + nodeDef: n, + }; + }); +} + +/** + * Build default parameters from a node definition. + * Reads each property's `default` value. For dual-mode triggers (OAuth2 + API key), + * sets `authentication` to the value that matches the OAuth2 credential. + */ +function buildDefaultParameters( + nodeDef: NodeDef, + matchedCredType: string +): Record { + const params: Record = {}; + + for (const prop of nodeDef.properties) { + if (prop.default !== undefined && prop.default !== '' && prop.default !== null) { + params[prop.name] = prop.default; + } + } + + // Find which authentication value activates our OAuth2 credential + const authProp = nodeDef.properties.find((p) => p.name === 'authentication'); + if (authProp && matchedCredType.toLowerCase().includes('oauth2')) { + // Check credential displayOptions to find the right authentication value + const cred = nodeDef.credentials?.find((c) => c.name === matchedCredType); + if (cred?.displayOptions?.show?.authentication) { + params.authentication = cred.displayOptions.show.authentication[0]; + } else if (authProp.options) { + // Fall back to finding the oAuth2 option + const oauthOption = authProp.options.find((o) => + String(o.value).toLowerCase().includes('oauth2') + ); + if (oauthOption) params.authentication = oauthOption.value; + } + } + + return params; +} + +// ─── Workflow builder ──────────────────────────────────────────────────────── + +function buildTestWorkflow( + trigger: TriggerInfo, + matchedCredType: string, + credentialId: string +): Record { + const parameters = buildDefaultParameters(trigger.nodeDef, matchedCredType); + + return { + name: `[Schema Capture] ${trigger.displayName}`, + nodes: [ + { + name: 'Trigger', + type: trigger.nodeType, + typeVersion: trigger.typeVersion, + position: [250, 300], + parameters, + credentials: { + [matchedCredType]: { id: credentialId, name: matchedCredType }, + }, + }, + { + name: 'NoOp', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + position: [500, 300], + parameters: {}, + }, + ], + connections: { + Trigger: { + main: [[{ node: 'NoOp', type: 'main', index: 0 }]], + }, + }, + settings: { executionOrder: 'v1' }, + }; +} + +// ─── Schema extraction ────────────────────────────────────────────────────── + +function inferSchemaFromValue(value: unknown): SchemaProperty { + if (value === null || value === undefined) return { type: 'null' }; + if (typeof value === 'string') return { type: 'string' }; + if (typeof value === 'number') return { type: Number.isInteger(value) ? 'integer' : 'number' }; + if (typeof value === 'boolean') return { type: 'boolean' }; + if (Array.isArray(value)) { + if (value.length === 0) return { type: 'array', items: { type: 'unknown' } }; + return { type: 'array', items: inferSchemaFromValue(value[0]) }; + } + if (typeof value === 'object') { + const properties: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + properties[key] = inferSchemaFromValue(val); + } + return { type: 'object', properties }; + } + return { type: 'unknown' }; +} + +function extractSchemaFromExecution( + execution: N8nExecution +): { schema: TriggerOutputSchema } | null { + const runData = execution.data?.resultData?.runData; + if (!runData) return null; + + const firstNodeData = Object.values(runData)[0]; + if (!firstNodeData?.[0]) return null; + + const mainOutput = firstNodeData[0]?.data?.main?.[0]; + if (!mainOutput?.[0]) return null; + + const json = mainOutput[0].json; + if (!json || typeof json !== 'object') return null; + + const properties: Record = {}; + for (const [key, value] of Object.entries(json)) { + properties[key] = inferSchemaFromValue(value); + } + + return { schema: { type: 'object', properties } }; +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + const args = process.argv.slice(2); + const filterTrigger = args.find((a) => a.startsWith('--trigger='))?.split('=')[1]; + const timeoutSec = parseInt(args.find((a) => a.startsWith('--timeout='))?.split('=')[1] ?? '30'); + const keepWorkflows = args.includes('--keep'); + const createOnly = args.includes('--create-only'); + + console.log(`n8n: ${N8N_HOST}`); + console.log(`Timeout: ${timeoutSec}s | Keep: ${keepWorkflows} | Create-only: ${createOnly}`); + if (filterTrigger) console.log(`Filter: ${filterTrigger}`); + console.log(); + + // Step 1: Build credential map + const credByType = new Map(Object.entries(KNOWN_CREDENTIALS)); + + // Step 2: Discover triggers from defaultNodes.json + let triggers = discoverTriggers(); + if (filterTrigger) { + triggers = triggers.filter((t) => + t.nodeType.toLowerCase().includes(filterTrigger.toLowerCase()) + ); + } + const triggersWithCreds = triggers.filter((t) => + t.credentialTypes.some((ct) => credByType.has(ct)) + ); + console.log(`${triggersWithCreds.length} triggers with matching credentials\n`); + + // Step 3: Check for existing [Schema Capture] workflows first + const existingWorkflows = await findExistingCaptureWorkflows(); + + const result: TriggerSchemaIndex = { + version: '2.0.0', + generatedAt: new Date().toISOString(), + source: 'execution', + triggers: {}, + stats: { total: triggersWithCreds.length, captured: 0, failed: 0, skipped: 0 }, + }; + + for (const trigger of triggersWithCreds) { + const credType = trigger.credentialTypes.find((ct) => credByType.has(ct))!; + const credId = credByType.get(credType)!; + + console.log(`── ${trigger.displayName} ──`); + + // Check if there's an existing workflow with executions + const existingWf = existingWorkflows.find((w) => w.triggerType === trigger.nodeType); + + // Use existing workflow or create a new one + let workflowId: string | null = existingWf?.id ?? null; + + if (existingWf) { + console.log(` Existing workflow: ${existingWf.id}`); + // Check if it already has execution data + const schema = await captureFromWorkflow(existingWf.id); + if (schema) { + result.triggers[trigger.nodeType] = { + outputSchema: schema.schema, + }; + result.stats.captured++; + const fields = Object.keys(schema.schema.properties); + console.log(` Captured ${fields.length} fields: ${fields.slice(0, 8).join(', ')}...`); + console.log(); + continue; + } + console.log(` No execution yet — activating...`); + } + + if (createOnly) { + if (!workflowId) { + try { + const workflow = buildTestWorkflow(trigger, credType, credId); + const created = await n8nRequest<{ id: string }>('POST', '/workflows', workflow); + workflowId = created.id; + console.log(` Created: ${workflowId} (${N8N_HOST}/workflow/${workflowId})`); + } catch (error) { + console.log(` ERROR creating: ${error instanceof Error ? error.message : error}`); + } + } + result.stats.skipped++; + console.log(); + continue; + } + + // Create if needed, activate, wait + const isNew = !workflowId; + try { + if (!workflowId) { + const workflow = buildTestWorkflow(trigger, credType, credId); + const created = await n8nRequest<{ id: string }>('POST', '/workflows', workflow); + workflowId = created.id; + console.log(` Created: ${workflowId}`); + } + + await n8nRequest('POST', `/workflows/${workflowId}/activate`); + console.log(` Activated. Waiting ${timeoutSec}s...`); + + if (trigger.triggerType === 'webhook') { + console.log(` WEBHOOK: trigger an event from the service now`); + } + + const schema = await waitForExecution(workflowId, timeoutSec); + if (schema) { + result.triggers[trigger.nodeType] = { + outputSchema: schema.schema, + }; + result.stats.captured++; + const fields = Object.keys(schema.schema.properties); + console.log(` Captured ${fields.length} fields: ${fields.slice(0, 8).join(', ')}...`); + } else { + result.stats.failed++; + console.log(` No execution data (timeout)`); + } + } catch (error) { + result.stats.failed++; + console.log(` ERROR: ${error instanceof Error ? error.message : error}`); + } finally { + if (workflowId) { + try { + await n8nRequest('POST', `/workflows/${workflowId}/deactivate`); + } catch { + /* ignore */ + } + // Only delete if we created it and --keep is not set + if (isNew && !keepWorkflows) { + try { + await n8nRequest('DELETE', `/workflows/${workflowId}`); + } catch { + /* ignore */ + } + } + } + } + + console.log(); + } + + // Save results + const outputPath = path.join(__dirname, '../src/data/triggerSchemaIndex.json'); + + // Merge with existing + if (fs.existsSync(outputPath)) { + const existing = JSON.parse(fs.readFileSync(outputPath, 'utf-8')); + if (existing.triggers) { + for (const [key, value] of Object.entries(existing.triggers)) { + if (!result.triggers[key]) { + result.triggers[key] = value as TriggerSchemaEntry; + } + } + } + } + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(result, null, 2)); + console.log('══════════════════════════════════════'); + console.log(`Captured: ${result.stats.captured}`); + console.log(`Failed: ${result.stats.failed}`); + console.log(`Skipped: ${result.stats.skipped}`); + console.log(`Total in index: ${Object.keys(result.triggers).length}`); + console.log(`Saved to ${outputPath}`); +} + +/** + * Find existing [Schema Capture] workflows and identify their trigger type. + */ +async function findExistingCaptureWorkflows(): Promise> { + try { + interface WorkflowListItem { + id: string; + name: string; + nodes: Array<{ type: string; name: string }>; + } + const { data } = await n8nRequest<{ data: WorkflowListItem[] }>('GET', '/workflows?limit=50'); + return data + .filter((w) => w.name.startsWith('[Schema Capture]')) + .map((w) => { + const triggerNode = w.nodes?.find((n) => n.name === 'Trigger'); + return { id: w.id, triggerType: triggerNode?.type || '' }; + }) + .filter((w) => w.triggerType); + } catch { + return []; + } +} + +/** + * Capture schema from an existing workflow's latest successful execution. + */ +async function captureFromWorkflow( + workflowId: string +): Promise<{ schema: TriggerOutputSchema } | null> { + try { + const { data: executions } = await n8nRequest<{ data: N8nExecution[] }>( + 'GET', + `/executions?workflowId=${workflowId}&includeData=true&limit=1` + ); + + if (executions.length === 0) return null; + + const exec = executions[0]; + if (exec.status !== 'success' || !exec.finished) return null; + + const fullExec = await n8nRequest( + 'GET', + `/executions/${exec.id}?includeData=true` + ); + return extractSchemaFromExecution(fullExec); + } catch { + return null; + } +} + +async function waitForExecution( + workflowId: string, + timeoutSec: number +): Promise<{ schema: TriggerOutputSchema } | null> { + const startTime = Date.now(); + const pollInterval = 2000; + + while (Date.now() - startTime < timeoutSec * 1000) { + const { data: executions } = await n8nRequest<{ data: N8nExecution[] }>( + 'GET', + `/executions?workflowId=${workflowId}&includeData=true&limit=1` + ); + + if (executions.length > 0) { + const exec = executions[0]; + if (exec.status === 'success' && exec.finished) { + const fullExec = await n8nRequest( + 'GET', + `/executions/${exec.id}?includeData=true` + ); + return extractSchemaFromExecution(fullExec); + } + if (exec.status === 'error') { + console.log(` Execution error`); + return null; + } + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + return null; +} + +main().catch((error) => { + console.error('Fatal:', error); + process.exit(1); +}); diff --git a/scripts/crawl-trigger-schemas.ts b/scripts/crawl-trigger-schemas.ts new file mode 100644 index 0000000..6b6ebee --- /dev/null +++ b/scripts/crawl-trigger-schemas.ts @@ -0,0 +1,771 @@ +#!/usr/bin/env npx ts-node +/** + * Crawler to extract output schemas from n8n trigger nodes. + * + * Fully dynamic — no hardcoded schema names. + * + * Strategy: + * 1. Find all *Trigger.node.js files in n8n-nodes-base + * 2. Detect trigger type (webhook vs polling) + * 3. Extract API URLs from trigger + GenericFunctions.js code + * 4. Match URLs to APIs.guru entries + * 5. For polling: find API endpoint path in OpenAPI spec → extract response schema + * 6. For webhook: find "event"/"webhook" schema in OpenAPI spec + * 7. Detect n8n transformations (simplifyOutput, etc.) and apply them + * 8. Save to triggerSchemaIndex.json + * + * Usage: bun run scripts/crawl-trigger-schemas.ts + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const N8N_NODES_PATH = path.join(__dirname, '../node_modules/n8n-nodes-base/dist/nodes'); +const CACHE_DIR = path.join(__dirname, '../.cache/openapi'); + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface SchemaProperty { + type: string; + description?: string; + properties?: Record; + items?: SchemaProperty; +} + +interface TriggerOutputSchema { + type: 'object'; + properties: Record; +} + +interface TriggerSchemaEntry { + triggerType: 'webhook' | 'polling' | 'unknown'; + serviceName: string | null; + openApiSource: string | null; + schemaSource: string | null; // How we found the schema (path match, event schema, etc.) + hasTransformation: boolean; + transformationFunction: string | null; + outputSchema: TriggerOutputSchema | null; + confidence: 'high' | 'medium' | 'low'; + reason?: string; +} + +interface TriggerSchemaIndex { + version: string; + generatedAt: string; + triggers: Record; + stats: { + total: number; + withSchema: number; + withoutSchema: number; + webhook: number; + polling: number; + unknown: number; + }; +} + +interface OpenApiSpec { + components?: { + schemas?: Record; + }; + definitions?: Record; // Swagger 2.0 + paths?: Record>; + webhooks?: Record; +} + +interface OpenApiMethodObj { + responses?: Record< + string, + { + content?: Record; + schema?: OpenApiSchemaObj; // Swagger 2.0 + } + >; +} + +interface OpenApiSchemaObj { + type?: string; + properties?: Record; + items?: OpenApiSchemaObj; + description?: string; + $ref?: string; + allOf?: OpenApiSchemaObj[]; + oneOf?: OpenApiSchemaObj[]; + anyOf?: OpenApiSchemaObj[]; + enum?: string[]; + nullable?: boolean; +} + +// ─── APIs.guru ─────────────────────────────────────────────────────────────── + +interface ApisGuruEntry { + preferred: string; + versions: Record< + string, + { + swaggerUrl: string; + openapiVer: string; + info: { + title: string; + 'x-providerName'?: string; + 'x-serviceName'?: string; + }; + } + >; +} + +type ApisGuruIndex = Record; + +let cachedGuruIndex: ApisGuruIndex | null = null; + +async function loadApisGuruIndex(): Promise { + if (cachedGuruIndex) return cachedGuruIndex; + + const cachePath = path.join(CACHE_DIR, 'apis-guru-index.json'); + if (fs.existsSync(cachePath)) { + const stat = fs.statSync(cachePath); + const ageHours = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60); + if (ageHours < 24) { + cachedGuruIndex = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + return cachedGuruIndex!; + } + } + + console.log('Fetching APIs.guru index...'); + const response = await fetch('https://api.apis.guru/v2/list.json'); + cachedGuruIndex = (await response.json()) as ApisGuruIndex; + + fs.mkdirSync(CACHE_DIR, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(cachedGuruIndex)); + console.log(`Cached (${Object.keys(cachedGuruIndex!).length} APIs)\n`); + + return cachedGuruIndex!; +} + +async function fetchOpenApiSpec(apisGuruKey: string): Promise { + const index = await loadApisGuruIndex(); + const entry = index[apisGuruKey]; + if (!entry) return null; + + const cachePath = path.join(CACHE_DIR, `${apisGuruKey.replace(/[/:]/g, '_')}.json`); + if (fs.existsSync(cachePath)) { + return JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + } + + const version = entry.versions[entry.preferred]; + if (!version) return null; + + console.log(` Fetching: ${apisGuruKey} (${entry.preferred})`); + const response = await fetch(version.swaggerUrl); + if (!response.ok) return null; + + const spec = (await response.json()) as OpenApiSpec; + fs.writeFileSync(cachePath, JSON.stringify(spec)); + return spec; +} + +// ─── Dynamic Service Detection ────────────────────────────────────────────── + +/** + * Extract API base URLs from trigger code + GenericFunctions.js. + * Returns domains like "api.stripe.com", "www.googleapis.com", etc. + */ +function extractApiDomains(triggerPath: string): string[] { + const domains: string[] = []; + + // Read trigger file + const triggerCode = fs.readFileSync(triggerPath, 'utf-8'); + + // Read GenericFunctions.js from same and parent directories + const dir = path.dirname(triggerPath); + const genericPaths = [ + path.join(dir, 'GenericFunctions.js'), + path.join(dir, '..', 'GenericFunctions.js'), + ]; + + let allCode = triggerCode; + for (const gp of genericPaths) { + if (fs.existsSync(gp)) { + allCode += '\n' + fs.readFileSync(gp, 'utf-8'); + } + } + + // Extract all URL domains + const urlPattern = /['"`]https?:\/\/([a-zA-Z0-9.-]+)/g; + for (const match of allCode.matchAll(urlPattern)) { + const domain = match[1]; + // Skip n8n.io, docs, icons, cdn + if ( + domain.includes('n8n.io') || + domain.includes('docs.') || + domain.includes('cdn.') || + domain.includes('icon') || + domain.includes('support.') + ) { + continue; + } + if (!domains.includes(domain)) { + domains.push(domain); + } + } + + return domains; +} + +/** + * Extract API endpoint paths from code. + * E.g., "/gmail/v1/users/me/messages" or "/v1/events" + */ +function extractApiEndpoints(triggerPath: string): string[] { + const triggerCode = fs.readFileSync(triggerPath, 'utf-8'); + const endpoints: string[] = []; + + // Pattern: apiRequest('GET', '/some/path') + const endpointPattern = + /(?:apiRequest|googleApiRequest)[\s\S]{0,50}?['"](?:GET|POST)['"][\s\S]{0,30}?['"](\/[a-zA-Z0-9/{}$._-]+)['"]/g; + for (const match of triggerCode.matchAll(endpointPattern)) { + endpoints.push(match[1]); + } + + // Pattern: endpoint = '/some/path' + const endpointVarPattern = /endpoint\s*=\s*['"`](\/[a-zA-Z0-9/{}._-]+)['"`]/g; + for (const match of triggerCode.matchAll(endpointVarPattern)) { + endpoints.push(match[1]); + } + + return endpoints; +} + +/** + * Map a domain to an APIs.guru key. + * "api.stripe.com" → "stripe.com" + * "www.googleapis.com" + endpoint "/gmail/..." → "googleapis.com:gmail" + */ +function domainToApisGuruKey( + domain: string, + endpoints: string[], + index: ApisGuruIndex +): string | null { + // Try direct match: "api.stripe.com" → "stripe.com" + const baseDomain = domain.replace(/^(api|www|app)\./, ''); + + if (index[baseDomain]) return baseDomain; + + // For googleapis.com, use the endpoint to determine the service + if (baseDomain === 'googleapis.com') { + for (const ep of endpoints) { + // /gmail/v1/... → googleapis.com:gmail + const serviceMatch = ep.match(/^\/(\w+)\//); + if (serviceMatch) { + const key = `googleapis.com:${serviceMatch[1]}`; + if (index[key]) return key; + } + } + return null; + } + + // Try with service name variations + for (const guruKey of Object.keys(index)) { + if (guruKey.startsWith(baseDomain)) return guruKey; + } + + return null; +} + +// ─── Schema Extraction ────────────────────────────────────────────────────── + +function resolveRef(spec: OpenApiSpec, ref: string): OpenApiSchemaObj | null { + const parts = ref.replace('#/', '').split('/'); + let current: unknown = spec; + for (const part of parts) { + if (typeof current !== 'object' || current === null) return null; + current = (current as Record)[part]; + } + return (current as OpenApiSchemaObj) ?? null; +} + +function extractSchemaProperties( + spec: OpenApiSpec, + schema: OpenApiSchemaObj, + depth = 0 +): Record { + if (depth > 4) return {}; + + if (schema.$ref) { + const resolved = resolveRef(spec, schema.$ref); + if (!resolved) return {}; + return extractSchemaProperties(spec, resolved, depth); + } + + if (schema.allOf) { + const merged: Record = {}; + for (const sub of schema.allOf) { + Object.assign(merged, extractSchemaProperties(spec, sub, depth + 1)); + } + return merged; + } + + if (schema.oneOf?.[0]) { + return extractSchemaProperties(spec, schema.oneOf[0], depth + 1); + } + if (schema.anyOf?.[0]) { + return extractSchemaProperties(spec, schema.anyOf[0], depth + 1); + } + + const properties = schema.properties; + if (!properties) return {}; + + const result: Record = {}; + for (const [key, prop] of Object.entries(properties)) { + result[key] = convertProperty(spec, prop, depth + 1); + } + return result; +} + +function convertProperty(spec: OpenApiSpec, prop: OpenApiSchemaObj, depth: number): SchemaProperty { + if (depth > 4) return { type: 'unknown' }; + + if (prop.$ref) { + const resolved = resolveRef(spec, prop.$ref); + if (!resolved) return { type: 'unknown' }; + return convertProperty(spec, resolved, depth); + } + + const type = prop.type ?? 'unknown'; + + if (type === 'object' && prop.properties) { + return { + type: 'object', + description: prop.description, + properties: extractSchemaProperties(spec, prop, depth), + }; + } + + if (type === 'array' && prop.items) { + return { + type: 'array', + description: prop.description, + items: convertProperty(spec, prop.items, depth + 1), + }; + } + + return { type, description: prop.description }; +} + +/** + * DYNAMIC: For polling triggers, find the schema from the API endpoint path. + * Matches endpoint path to OpenAPI paths → extracts 200 response schema. + */ +function extractSchemaFromEndpoint( + spec: OpenApiSpec, + endpoints: string[] +): { schema: TriggerOutputSchema; source: string } | null { + const specPaths = spec.paths; + if (!specPaths) return null; + + for (const endpoint of endpoints) { + // Normalize: replace template vars ${xxx} with {xxx} + const normalized = endpoint.replace(/\$\{[^}]+\}/g, '{id}'); + + for (const [specPath, methods] of Object.entries(specPaths)) { + // Match paths: /gmail/v1/users/{userId}/messages/{id} + // against: /gmail/v1/users/me/messages/${message.id} + if (!pathsMatch(normalized, specPath)) continue; + + const methodObj = methods as Record; + const getMethod = methodObj['get'] ?? methodObj['post']; + if (!getMethod?.responses) continue; + + const resp200 = getMethod.responses['200'] ?? getMethod.responses['201']; + if (!resp200) continue; + + // OpenAPI 3.x: content.application/json.schema + let responseSchema: OpenApiSchemaObj | null = null; + if (resp200.content) { + const jsonContent = resp200.content['application/json'] ?? resp200.content['*/*']; + responseSchema = jsonContent?.schema ?? null; + } + // Swagger 2.0: schema directly on response + if (!responseSchema && resp200.schema) { + responseSchema = resp200.schema; + } + + if (!responseSchema) continue; + + const properties = extractSchemaProperties(spec, responseSchema); + if (Object.keys(properties).length > 0) { + return { + schema: { type: 'object', properties }, + source: `path:${specPath}`, + }; + } + } + } + + return null; +} + +/** + * Match an n8n endpoint against an OpenAPI path pattern. + * "/gmail/v1/users/me/messages/{id}" matches "/gmail/v1/users/{userId}/messages/{id}" + */ +function pathsMatch(n8nPath: string, specPath: string): boolean { + const n8nParts = n8nPath.split('/').filter(Boolean); + const specParts = specPath.split('/').filter(Boolean); + + if (n8nParts.length !== specParts.length) return false; + + for (let i = 0; i < n8nParts.length; i++) { + const n8n = n8nParts[i]; + const spec = specParts[i]; + // Template params match anything + if (spec.startsWith('{') || n8n.startsWith('{')) continue; + // "me" matches "{userId}" equivalent + if (n8n === 'me' && spec.startsWith('{')) continue; + if (n8n !== spec) return false; + } + + return true; +} + +/** + * DYNAMIC: For webhook triggers, find an "event" or "webhook" schema. + * Searches components/schemas for patterns like "event", "webhook_event", etc. + */ +function extractEventSchema( + spec: OpenApiSpec +): { schema: TriggerOutputSchema; source: string } | null { + const schemas = spec.components?.schemas ?? spec.definitions ?? {}; + + // Priority order: look for event-related schema names + const candidates = [ + 'event', + 'Event', + 'WebhookEvent', + 'webhook_event', + 'EventResponse', + 'Webhook', + ]; + + for (const candidate of candidates) { + const schema = schemas[candidate]; + if (!schema) continue; + + const properties = extractSchemaProperties(spec, schema); + if (Object.keys(properties).length > 0) { + return { + schema: { type: 'object', properties }, + source: `schema:${candidate}`, + }; + } + } + + // Fallback: case-insensitive search + for (const [name, schema] of Object.entries(schemas)) { + if (/^(webhook_?)?event$/i.test(name) || /^event_?(payload|data|body)$/i.test(name)) { + const properties = extractSchemaProperties(spec, schema); + if (Object.keys(properties).length > 0) { + return { + schema: { type: 'object', properties }, + source: `schema:${name}`, + }; + } + } + } + + return null; +} + +// ─── Trigger File Analysis ────────────────────────────────────────────────── + +function findTriggerFiles(): string[] { + const triggers: string[] = []; + + function walk(dir: string) { + const files = fs.readdirSync(dir); + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + walk(filePath); + } else if (file.endsWith('Trigger.node.js') && !file.includes('.map')) { + triggers.push(filePath); + } + } + } + + walk(N8N_NODES_PATH); + return triggers; +} + +function getNodeType(filePath: string): string { + const fileName = path.basename(filePath, '.node.js'); + const nodeName = fileName.charAt(0).toLowerCase() + fileName.slice(1); + return `n8n-nodes-base.${nodeName}`; +} + +function detectTriggerType(content: string): 'webhook' | 'polling' | 'unknown' { + const hasWebhook = /async\s+webhook\s*\(/.test(content) || /webhook\s*\(\s*\)\s*{/.test(content); + const hasPoll = /async\s+poll\s*\(/.test(content) || /poll\s*\(\s*\)\s*{/.test(content); + + if (hasWebhook) return 'webhook'; + if (hasPoll) return 'polling'; + return 'unknown'; +} + +// ─── Transformation Detection ─────────────────────────────────────────────── + +interface TransformationResult { + hasTransformation: boolean; + functionName: string | null; + fieldsRemoved: string[]; + fieldsAdded: Record; +} + +function detectTransformations(triggerPath: string, triggerCode: string): TransformationResult { + const noTransform: TransformationResult = { + hasTransformation: false, + functionName: null, + fieldsRemoved: [], + fieldsAdded: {}, + }; + + const transformPatterns = [/simplifyOutput/, /formatOutput/, /transformData/]; + + let transformName: string | null = null; + for (const pattern of transformPatterns) { + if (pattern.test(triggerCode)) { + transformName = pattern.source; + break; + } + } + + if (!transformName) return noTransform; + + const dir = path.dirname(triggerPath); + const genericPath = path.join(dir, 'GenericFunctions.js'); + if (!fs.existsSync(genericPath)) return noTransform; + + const genericCode = fs.readFileSync(genericPath, 'utf-8'); + const result: TransformationResult = { + hasTransformation: true, + functionName: transformName, + fieldsRemoved: [], + fieldsAdded: {}, + }; + + // Detect field deletions: delete item.fieldName + for (const match of genericCode.matchAll(/delete\s+item\.(\w+)/g)) { + const field = match[1]; + if (!result.fieldsRemoved.includes(field)) { + result.fieldsRemoved.push(field); + } + } + + // Detect field additions: item.fieldName = ... + for (const match of genericCode.matchAll(/item\.(\w+)\s*=/g)) { + const field = match[1]; + if (field !== 'json' && !result.fieldsAdded[field]) { + result.fieldsAdded[field] = { type: 'unknown' }; + } + } + + // Dynamic header extraction: item[header.name] = header.value + // The actual headers are determined by metadataHeaders in the trigger code + if (/item\[header\.name\]\s*=\s*header\.value/.test(genericCode)) { + const metadataMatch = triggerCode.match(/metadataHeaders\s*=\s*\[(.*?)\]/s); + if (metadataMatch) { + const headers = + metadataMatch[1].match(/['"](\w+)['"]/g)?.map((h) => h.replace(/['"]/g, '')) ?? []; + for (const header of headers) { + result.fieldsAdded[header] = { type: 'string' }; + } + } + } + + // Labels transformation: item.labels = labels.filter(...) + if (/item\.labels\s*=/.test(genericCode)) { + result.fieldsAdded['labels'] = { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }; + } + + return result; +} + +function applyTransformations( + schema: TriggerOutputSchema, + transformations: TransformationResult +): TriggerOutputSchema { + const result: TriggerOutputSchema = { + type: 'object', + properties: { ...schema.properties }, + }; + + for (const field of transformations.fieldsRemoved) { + delete result.properties[field]; + } + + for (const [field, prop] of Object.entries(transformations.fieldsAdded)) { + result.properties[field] = prop; + } + + return result; +} + +// ─── Main Crawler ─────────────────────────────────────────────────────────── + +async function crawlTriggers(): Promise { + console.log('Finding trigger files...'); + const triggerFiles = findTriggerFiles(); + console.log(`Found ${triggerFiles.length} triggers\n`); + + const guruIndex = await loadApisGuruIndex(); + + const result: TriggerSchemaIndex = { + version: '2.0.0', + generatedAt: new Date().toISOString(), + triggers: {}, + stats: { + total: triggerFiles.length, + withSchema: 0, + withoutSchema: 0, + webhook: 0, + polling: 0, + unknown: 0, + }, + }; + + for (const filePath of triggerFiles) { + const nodeType = getNodeType(filePath); + const triggerCode = fs.readFileSync(filePath, 'utf-8'); + const triggerType = detectTriggerType(triggerCode); + + console.log(`${nodeType} (${triggerType})`); + + if (triggerType === 'webhook') result.stats.webhook++; + else if (triggerType === 'polling') result.stats.polling++; + else result.stats.unknown++; + + // Step 1: Extract API domains and endpoints from code + const domains = extractApiDomains(filePath); + const endpoints = extractApiEndpoints(filePath); + + // Step 2: Find APIs.guru key from domains + let apisGuruKey: string | null = null; + for (const domain of domains) { + apisGuruKey = domainToApisGuruKey(domain, endpoints, guruIndex); + if (apisGuruKey) break; + } + + let outputSchema: TriggerOutputSchema | null = null; + let openApiSource: string | null = null; + let schemaSource: string | null = null; + + if (apisGuruKey) { + const spec = await fetchOpenApiSpec(apisGuruKey); + if (spec) { + openApiSource = apisGuruKey; + + if (triggerType === 'polling' && endpoints.length > 0) { + // Polling: match endpoint path → response schema + const pathResult = extractSchemaFromEndpoint(spec, endpoints); + if (pathResult) { + outputSchema = pathResult.schema; + schemaSource = pathResult.source; + console.log( + ` -> ${pathResult.source} (${Object.keys(outputSchema.properties).length} fields)` + ); + } + } + + if (!outputSchema && triggerType === 'webhook') { + // Webhook: find event/webhook schema + const eventResult = extractEventSchema(spec); + if (eventResult) { + outputSchema = eventResult.schema; + schemaSource = eventResult.source; + console.log( + ` -> ${eventResult.source} (${Object.keys(outputSchema.properties).length} fields)` + ); + } + } + + if (!outputSchema) { + console.log(` -> No matching schema in ${apisGuruKey}`); + } + } + } else if (domains.length > 0) { + console.log(` -> Domains found [${domains.slice(0, 3).join(', ')}] but no APIs.guru match`); + } + + // Step 3: Detect and apply transformations + const transformations = detectTransformations(filePath, triggerCode); + if (transformations.hasTransformation) { + console.log( + ` -> Transform: ${transformations.functionName} (-${transformations.fieldsRemoved.length} +${Object.keys(transformations.fieldsAdded).length})` + ); + + if (outputSchema) { + outputSchema = applyTransformations(outputSchema, transformations); + console.log(` -> After transform: ${Object.keys(outputSchema.properties).length} fields`); + } + } + + const hasSchema = outputSchema !== null; + if (hasSchema) result.stats.withSchema++; + else result.stats.withoutSchema++; + + result.triggers[nodeType] = { + triggerType, + serviceName: apisGuruKey, + openApiSource, + schemaSource, + hasTransformation: transformations.hasTransformation, + transformationFunction: transformations.functionName, + outputSchema, + confidence: hasSchema ? 'high' : 'low', + reason: hasSchema + ? undefined + : !apisGuruKey + ? 'No APIs.guru match' + : 'No schema found in spec', + }; + } + + return result; +} + +// ─── Run ───────────────────────────────────────────────────────────────────── + +crawlTriggers().then((result) => { + console.log('\n══════════════════════════════════════'); + console.log(`Total: ${result.stats.total}`); + console.log(`With schema: ${result.stats.withSchema}`); + console.log(`Without schema: ${result.stats.withoutSchema}`); + console.log( + `Webhook: ${result.stats.webhook} | Polling: ${result.stats.polling} | Unknown: ${result.stats.unknown}` + ); + + const outputPath = path.join(__dirname, '../src/data/triggerSchemaIndex.json'); + fs.writeFileSync(outputPath, JSON.stringify(result, null, 2)); + console.log(`\nSaved to ${outputPath}`); + + const withSchema = Object.entries(result.triggers).filter(([, v]) => v.outputSchema); + console.log(`\nTriggers with schemas (${withSchema.length}):`); + for (const [name, entry] of withSchema) { + const fieldCount = entry.outputSchema ? Object.keys(entry.outputSchema.properties).length : 0; + const transform = entry.hasTransformation ? ' [transformed]' : ''; + console.log(` ${name}: ${fieldCount} fields via ${entry.schemaSource}${transform}`); + } +}); diff --git a/scripts/create-credentials.ts b/scripts/create-credentials.ts new file mode 100644 index 0000000..984a711 --- /dev/null +++ b/scripts/create-credentials.ts @@ -0,0 +1,280 @@ +#!/usr/bin/env npx ts-node +/** + * Create n8n credentials for trigger schema capture. + * + * Supports two modes: + * 1. API key / token credentials → created and immediately usable + * 2. OAuth2 credentials → created with clientId/clientSecret, user must complete flow in n8n UI + * + * Usage: + * N8N_HOST=http://localhost:5678 N8N_API_KEY=xxx bun run scripts/create-credentials.ts + * + * Required environment variables per service: + * + * LINEAR (API key): LINEAR_API_KEY + * LINEAR (OAuth2): LINEAR_CLIENT_ID, LINEAR_CLIENT_SECRET + * + * SLACK: SLACK_ACCESS_TOKEN (xoxb-... bot token) + * SLACK_SIGNING_SECRET (optional) + * + * GITHUB (token): GITHUB_USER, GITHUB_ACCESS_TOKEN + * GITHUB (OAuth2): GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET + * + * NOTION: NOTION_API_KEY (internal integration secret) + * + * TWITTER (OAuth 1.0a): TWITTER_API_KEY, TWITTER_API_SECRET, + * TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET + * + * Set --list to show existing credentials instead of creating. + * Set --delete-all to remove all credentials with "[Auto]" prefix. + */ + +const N8N_HOST = process.env.N8N_HOST; +const N8N_API_KEY = process.env.N8N_API_KEY; + +if (!N8N_HOST || !N8N_API_KEY) { + console.error('Missing N8N_HOST or N8N_API_KEY environment variables'); + process.exit(1); +} + +// ─── n8n API helpers ───────────────────────────────────────────────────────── + +async function n8nRequest(method: string, endpoint: string, body?: unknown): Promise { + const url = `${N8N_HOST}/api/v1${endpoint}`; + const options: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + 'X-N8N-API-KEY': N8N_API_KEY!, + }, + }; + if (body) options.body = JSON.stringify(body); + + const response = await fetch(url, options); + if (response.status === 204) return undefined as T; + if (!response.ok) { + const text = await response.text(); + throw new Error(`n8n API ${method} ${endpoint}: ${response.status} ${text}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : undefined; +} + +interface N8nCredential { + id: string; + name: string; + type: string; +} + +async function listCredentials(): Promise { + const result = await n8nRequest<{ data: N8nCredential[] }>('GET', '/credentials'); + return result.data; +} + +async function createCredential( + name: string, + type: string, + data: Record +): Promise { + return n8nRequest('POST', '/credentials', { name, type, data }); +} + +async function deleteCredential(id: string): Promise { + await n8nRequest('DELETE', `/credentials/${id}`); +} + +// ─── Credential definitions ────────────────────────────────────────────────── + +interface CredentialConfig { + name: string; + type: string; + data: Record; + oauth2Flow?: boolean; // true = user must complete OAuth in n8n UI +} + +function getCredentialConfigs(): CredentialConfig[] { + const configs: CredentialConfig[] = []; + + // ── Linear ── + if (process.env.LINEAR_API_KEY) { + configs.push({ + name: '[Auto] Linear API Key', + type: 'linearApi', + data: { apiKey: process.env.LINEAR_API_KEY }, + }); + } + if (process.env.LINEAR_CLIENT_ID && process.env.LINEAR_CLIENT_SECRET) { + configs.push({ + name: '[Auto] Linear OAuth2', + type: 'linearOAuth2Api', + data: { + clientId: process.env.LINEAR_CLIENT_ID, + clientSecret: process.env.LINEAR_CLIENT_SECRET, + actor: 'user', + includeAdminScope: true, // needed for webhooks + }, + oauth2Flow: true, + }); + } + + // ── Slack ── + if (process.env.SLACK_ACCESS_TOKEN) { + configs.push({ + name: '[Auto] Slack Bot Token', + type: 'slackApi', + data: { + accessToken: process.env.SLACK_ACCESS_TOKEN, + ...(process.env.SLACK_SIGNING_SECRET && { + signatureSecret: process.env.SLACK_SIGNING_SECRET, + }), + }, + }); + } + + // ── GitHub ── + if (process.env.GITHUB_ACCESS_TOKEN) { + configs.push({ + name: '[Auto] GitHub Token', + type: 'githubApi', + data: { + user: process.env.GITHUB_USER || '', + accessToken: process.env.GITHUB_ACCESS_TOKEN, + server: 'https://api.github.com', + }, + }); + } + if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { + configs.push({ + name: '[Auto] GitHub OAuth2', + type: 'githubOAuth2Api', + data: { + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + server: 'https://api.github.com', + }, + oauth2Flow: true, + }); + } + + // ── Notion ── + if (process.env.NOTION_API_KEY) { + configs.push({ + name: '[Auto] Notion Integration', + type: 'notionApi', + data: { apiKey: process.env.NOTION_API_KEY }, + }); + } + + // ── Twitter/X OAuth 1.0a ── + if (process.env.TWITTER_API_KEY && process.env.TWITTER_ACCESS_TOKEN) { + configs.push({ + name: '[Auto] Twitter OAuth1', + type: 'twitterOAuth1Api', + data: { + consumerKey: process.env.TWITTER_API_KEY, + consumerSecret: process.env.TWITTER_API_SECRET || '', + accessToken: process.env.TWITTER_ACCESS_TOKEN, + accessTokenSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET || '', + }, + }); + } + + return configs; +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + const args = process.argv.slice(2); + + // --list mode + if (args.includes('--list')) { + const creds = await listCredentials(); + console.log(`Found ${creds.length} credentials:\n`); + for (const cred of creds) { + console.log(` [${cred.id}] ${cred.type} — "${cred.name}"`); + } + return; + } + + // --delete-all mode (only [Auto] prefixed ones) + if (args.includes('--delete-all')) { + const creds = await listCredentials(); + const autoCreated = creds.filter((c) => c.name.startsWith('[Auto]')); + console.log(`Deleting ${autoCreated.length} auto-created credentials...`); + for (const cred of autoCreated) { + await deleteCredential(cred.id); + console.log(` Deleted: ${cred.type} — "${cred.name}"`); + } + return; + } + + // Create credentials + const configs = getCredentialConfigs(); + + if (configs.length === 0) { + console.error('No credential environment variables found.\n'); + console.error('Set at least one of:'); + console.error(' LINEAR_API_KEY — Linear personal API key'); + console.error(' SLACK_ACCESS_TOKEN — Slack bot token (xoxb-...)'); + console.error(' GITHUB_ACCESS_TOKEN — GitHub personal access token'); + console.error(' NOTION_API_KEY — Notion internal integration secret'); + console.error(' TWITTER_API_KEY + TWITTER_ACCESS_TOKEN — Twitter OAuth 1.0a'); + console.error('\nFor OAuth2 flow (requires n8n UI to complete):'); + console.error(' LINEAR_CLIENT_ID + LINEAR_CLIENT_SECRET'); + console.error(' GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET'); + process.exit(1); + } + + // Check for existing credentials to avoid duplicates + const existing = await listCredentials(); + const existingTypes = new Set(existing.map((c) => c.type)); + + console.log(`Creating ${configs.length} credentials in n8n...\n`); + + const needsOAuth: string[] = []; + + for (const config of configs) { + if (existingTypes.has(config.type)) { + const existingCred = existing.find((c) => c.type === config.type)!; + console.log( + ` SKIP ${config.type} — already exists: "${existingCred.name}" (${existingCred.id})` + ); + continue; + } + + try { + const created = await createCredential(config.name, config.type, config.data); + console.log(` OK ${config.type} — "${config.name}" (${created.id})`); + + if (config.oauth2Flow) { + needsOAuth.push(`${config.type} → ${N8N_HOST}/credentials/${created.id}`); + } + } catch (error) { + console.log(` FAIL ${config.type} — ${error instanceof Error ? error.message : error}`); + } + } + + if (needsOAuth.length > 0) { + console.log('\n── OAuth2 credentials need manual authorization ──'); + console.log('Open these URLs in your browser to complete the OAuth flow:\n'); + for (const url of needsOAuth) { + console.log(` ${url}`); + } + } + + console.log('\n── Current credentials ──'); + const allCreds = await listCredentials(); + for (const cred of allCreds) { + console.log(` [${cred.id}] ${cred.type} — "${cred.name}"`); + } + + console.log(`\nTotal: ${allCreds.length} credentials`); + console.log('\nRun the capture script next:'); + console.log(` N8N_HOST=${N8N_HOST} N8N_API_KEY=*** bun run scripts/capture-trigger-schemas.ts`); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/trigger-oauth2-status.md b/scripts/trigger-oauth2-status.md new file mode 100644 index 0000000..1d634b3 --- /dev/null +++ b/scripts/trigger-oauth2-status.md @@ -0,0 +1,65 @@ +# Trigger OAuth2 Status + +31 n8n triggers support OAuth2 credentials. This file tracks which ones have credentials created and tested. + +## Credentials Created (7/31) + +| # | Trigger | OAuth2 Credential Type | n8n Cred ID | Status | +|---|---------|----------------------|-------------|--------| +| 1 | GmailTrigger | `gmailOAuth2` | `hjSOdmEwxXzQpj3V` | created | +| 2 | GoogleCalendarTrigger | `googleCalendarOAuth2Api` | `yHjZgYhYPxTOFwdr` | created | +| 3 | GoogleDriveTrigger | `googleDriveOAuth2Api` | `LoKYrCVJOpU8PhAM` | created | +| 4 | GoogleSheetsTrigger | `googleSheetsTriggerOAuth2Api` | `UcHkFKN0Khm7z1O3` | created | +| 5 | GoogleBusinessProfileTrigger | `googleBusinessProfileOAuth2Api` | `lo6OS1caDfS84dgR` | created | +| 6 | GithubTrigger | `githubOAuth2Api` | `T3JHHfCQmwbQgtEr` | created | +| 7 | LinearTrigger | `linearOAuth2Api` | `jADHC6LxJ8ax7JMc` | created | + +## Not Created Yet (24/31) + +Need OAuth app credentials (clientId + clientSecret) for these services: + +| # | Trigger | OAuth2 Credential Type | Notes | +|---|---------|----------------------|-------| +| 7 | AcuitySchedulingTrigger | `acuitySchedulingOAuth2Api` | | +| 8 | AirtableTrigger | `airtableOAuth2Api` | | +| 9 | AsanaTrigger | `asanaOAuth2Api` | | +| 10 | BoxTrigger | `boxOAuth2Api` | | +| 11 | CalendlyTrigger | `calendlyOAuth2Api` | | +| 12 | CiscoWebexTrigger | `ciscoWebexOAuth2Api` | | +| 13 | ClickUpTrigger | `clickUpOAuth2Api` | | +| 14 | EventbriteTrigger | `eventbriteOAuth2Api` | | +| 15 | FacebookLeadAdsTrigger | `facebookLeadAdsOAuth2Api` | | +| 16 | FormstackTrigger | `formstackOAuth2Api` | | +| 17 | GetResponseTrigger | `getResponseOAuth2Api` | | +| 18 | HelpScoutTrigger | `helpScoutOAuth2Api` | | +| 19 | KeapTrigger | `keapOAuth2Api` | | +| 20 | MailchimpTrigger | `mailchimpOAuth2Api` | | +| 21 | MauticTrigger | `mauticOAuth2Api` | | +| 22 | MicrosoftOneDriveTrigger | `microsoftOneDriveOAuth2Api` | | +| 23 | MicrosoftOutlookTrigger | `microsoftOutlookOAuth2Api` | | +| 24 | MicrosoftTeamsTrigger | `microsoftTeamsOAuth2Api` | | +| 25 | PipedriveTrigger | `pipedriveOAuth2Api` | | +| 26 | SalesforceTrigger | `salesforceOAuth2Api` | | +| 27 | ShopifyTrigger | `shopifyOAuth2Api` | | +| 28 | StravaTrigger | `stravaOAuth2Api` | | +| 29 | SurveyMonkeyTrigger | `surveyMonkeyOAuth2Api` | | +| 30 | TypeformTrigger | `typeformOAuth2Api` | | +| 31 | ZendeskTrigger | `zendeskOAuth2Api` | | + +## Not Supported (triggers without OAuth2) + +These triggers only accept API key/token credentials. The bridge must convert OAuth2 tokens to the right format. + +| Trigger | Required Credential | Data Format | +|---------|-------------------|-------------| +| SlackTrigger | `slackApi` | `{ accessToken: "xoxb-..." }` | +| NotionTrigger | `notionApi` | `{ apiKey: "secret_..." }` | +| StripeTrigger | `stripeApi` | `{ apiKey: "sk_..." }` | +| TelegramTrigger | `telegramApi` | `{ accessToken: "bot..." }` | +| + 49 others | various `*Api` | API key/token | + +## Next Steps + +1. Complete OAuth flow in n8n UI for the 6 created credentials +2. Run `capture-trigger-schemas.ts` to capture real output schemas +3. Create OAuth apps for remaining 25 services as needed \ No newline at end of file diff --git a/src/actions/createWorkflow.ts b/src/actions/createWorkflow.ts index 9768aac..826b763 100644 --- a/src/actions/createWorkflow.ts +++ b/src/actions/createWorkflow.ts @@ -266,20 +266,7 @@ export const createWorkflowAction: Action & { 'about the draft — including "yes", "ok", "deploy it", "cancel", or modification requests. ' + 'Never reply with text only when a draft is pending.', - parameters: { - intent: { - type: 'string', - description: - 'Explicit intent when a draft is pending. Use "confirm" to deploy, "cancel" to discard, ' + - '"modify" to change the draft, or "new" to start fresh. Required when draft exists.', - required: false, - }, - modification: { - type: 'string', - description: 'The modification request when intent is "modify". Describes what to change.', - required: false, - }, - }, + parameters: {}, validate: async (runtime: IAgentRuntime): Promise => { return !!runtime.getService(N8N_WORKFLOW_SERVICE_TYPE); @@ -308,24 +295,12 @@ export const createWorkflowAction: Action & { return { success: false }; } - const content = message.content as Content & { - actionParams?: { intent?: string; modification?: string }; - actionInput?: { intent?: string; modification?: string }; - }; + const content = message.content as Content; const userText = (content.text ?? '').trim(); const userId = message.entityId; const cacheKey = `workflow_draft:${userId}`; const generationContext = buildConversationContext(message, state); - // Extract action parameters from multiple sources (multi-step agent, direct call, state) - const actionParams = - content.actionParams || - content.actionInput || - (state?.data as { actionParams?: { intent?: string; modification?: string } } | undefined) - ?.actionParams || - (_options as { intent?: string; modification?: string } | undefined) || - {}; - try { let existingDraft = await runtime.getCache(cacheKey); @@ -336,28 +311,30 @@ export const createWorkflowAction: Action & { } if (existingDraft) { - const explicitIntent = actionParams.intent; - - let intentResult: { intent: string; reason: string; modificationRequest?: string }; - - if (explicitIntent && ['confirm', 'cancel', 'modify', 'new'].includes(explicitIntent)) { - intentResult = { - intent: explicitIntent, - reason: 'Explicit intent from action parameters', - modificationRequest: actionParams.modification, - }; + // Guard: if the draft was created by this same message, re-show preview instead of classifying. + // This prevents the multi-step agent from auto-confirming in the same turn. + if (existingDraft.originMessageId && existingDraft.originMessageId === message.id) { logger.info( { src: 'plugin:n8n-workflow:action:create' }, - `Using explicit intent: ${explicitIntent}` + 'Same message as draft origin — re-showing preview' ); - } else { - intentResult = await classifyDraftIntent(runtime, userText, existingDraft); - logger.info( - { src: 'plugin:n8n-workflow:action:create' }, - `Draft intent: ${intentResult.intent} — ${intentResult.reason}` + const text = await formatActionResponse( + runtime, + 'PREVIEW', + buildPreviewData(existingDraft.workflow) ); + if (callback) { + await callback({ text, success: true }); + } + return { success: true, data: { awaitingUserInput: true } }; } + const intentResult = await classifyDraftIntent(runtime, userText, existingDraft); + logger.info( + { src: 'plugin:n8n-workflow:action:create' }, + `Draft intent: ${intentResult.intent} — ${intentResult.reason}` + ); + // If the draft was awaiting clarification and the user answered, treat "confirm" as "modify" // to regenerate with the user's answers instead of deploying an incomplete draft. const effectiveIntent = @@ -477,6 +454,7 @@ export const createWorkflowAction: Action & { generationContext, userId, cacheKey, + message.id ?? '', callback ); } catch (genError) { @@ -528,6 +506,7 @@ export const createWorkflowAction: Action & { generationContext, userId, cacheKey, + message.id ?? '', callback ); } catch (error) { @@ -565,6 +544,7 @@ async function generateAndPreview( prompt: string, userId: string, cacheKey: string, + messageId: string, callback?: HandlerCallback ): Promise { logger.info( @@ -579,6 +559,7 @@ async function generateAndPreview( prompt, userId, createdAt: Date.now(), + originMessageId: messageId, }; await runtime.setCache(cacheKey, draft); diff --git a/src/prompts/fieldCorrection.ts b/src/prompts/fieldCorrection.ts index 561b1e8..7e6bdb7 100644 --- a/src/prompts/fieldCorrection.ts +++ b/src/prompts/fieldCorrection.ts @@ -1,15 +1,17 @@ -export const FIELD_CORRECTION_SYSTEM_PROMPT = `Fix the n8n expression to use a valid field path. +export const FIELD_CORRECTION_SYSTEM_PROMPT = `Fix the n8n field reference to use a valid field path. You will receive: -1. An expression with an invalid field reference -2. The available fields from the source node's output schema +1. A $json reference with an invalid field +2. The available fields with their types from the source node's output schema -Return ONLY the corrected expression. No explanation. +Pick the field that best matches the intent. Pay attention to types: if the expression expects text content, pick a string field, not an object or array. + +Return ONLY the corrected $json reference. No explanation, no {{ }} wrapping. Example: -- Expression: {{ $json.sender }} -- Available: from.value[0].address, from.value[0].name, subject, id -- Output: {{ $json.from.value[0].address }}`; +- Expression: $json.sender +- Available: from.value[0].address (string), from.value[0].name (string), subject (string), id (string) +- Output: $json.from.value[0].address`; export const FIELD_CORRECTION_USER_PROMPT = `Expression: {expression} Available fields: diff --git a/src/services/n8n-workflow-service.ts b/src/services/n8n-workflow-service.ts index 1a400f1..9c3f5db 100644 --- a/src/services/n8n-workflow-service.ts +++ b/src/services/n8n-workflow-service.ts @@ -16,6 +16,7 @@ import { validateNodeParameters, validateNodeInputs, validateOutputReferences, + normalizeTriggerSimpleParam, } from '../utils/workflow'; import { resolveCredentials } from '../utils/credentialResolver'; import type { @@ -240,6 +241,7 @@ export class N8nWorkflowService extends Service { `Generated workflow with ${workflow.nodes?.length || 0} nodes` ); + normalizeTriggerSimpleParam(workflow); const invalidRefs = validateOutputReferences(workflow); if (invalidRefs.length > 0) { logger.debug( @@ -307,6 +309,7 @@ export class N8nWorkflowService extends Service { combinedDefs ); + normalizeTriggerSimpleParam(workflow); const invalidRefs = validateOutputReferences(workflow); if (invalidRefs.length > 0) { logger.debug( diff --git a/src/types/index.ts b/src/types/index.ts index aea8029..bb2ef27 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -306,6 +306,8 @@ export interface WorkflowDraft { prompt: string; userId: string; createdAt: number; + /** ID of the message that created this draft — used to prevent same-turn auto-confirm. */ + originMessageId?: string; } export interface DraftIntentResult { diff --git a/src/utils/generation.ts b/src/utils/generation.ts index 3a3c964..c4fd3ff 100644 --- a/src/utils/generation.ts +++ b/src/utils/generation.ts @@ -187,11 +187,6 @@ ${userMessage}`, return { intent: 'show_preview', reason: 'Could not classify intent — re-showing preview' }; } - logger.debug( - { src: 'plugin:n8n-workflow:generation:intent' }, - `Draft intent: ${result.intent} — ${result.reason}` - ); - return result; } diff --git a/src/utils/outputSchema.ts b/src/utils/outputSchema.ts index c8b8f36..58a0442 100644 --- a/src/utils/outputSchema.ts +++ b/src/utils/outputSchema.ts @@ -4,6 +4,7 @@ */ import type { ExpressionRef, SchemaContent } from '../types/index'; import schemaIndex from '../data/schemaIndex.json' assert { type: 'json' }; +import triggerSchemaIndex from '../data/triggerSchemaIndex.json' assert { type: 'json' }; type SchemasByResource = Record>; @@ -19,6 +20,9 @@ interface SchemaIndex { } const SCHEMA_INDEX = schemaIndex as unknown as SchemaIndex; +const TRIGGER_SCHEMAS = ( + triggerSchemaIndex as unknown as { triggers: Record } +).triggers; export interface OutputSchemaResult { schema: SchemaContent; @@ -75,6 +79,23 @@ export function loadOutputSchema( }; } +export function loadTriggerOutputSchema( + nodeType: string, + parameters?: Record +): OutputSchemaResult | null { + if (parameters?.simple === false) { + return null; + } + const entry = TRIGGER_SCHEMAS[nodeType]; + if (!entry?.outputSchema?.properties || Object.keys(entry.outputSchema.properties).length === 0) { + return null; + } + return { + schema: entry.outputSchema, + fields: getTopLevelFields(entry.outputSchema), + }; +} + export function getTopLevelFields(schema: SchemaContent): string[] { if (!schema.properties) { return []; @@ -84,34 +105,42 @@ export function getTopLevelFields(schema: SchemaContent): string[] { /** Returns all field paths including nested (e.g., "from.value[0].address") */ export function getAllFieldPaths(schema: SchemaContent, prefix = ''): string[] { - const paths: string[] = []; + return getAllFieldPathsTyped(schema, prefix).map((f) => f.path); +} + +/** Returns field paths with their types (e.g., "snippet: string", "payload: object"). */ +export function getAllFieldPathsTyped( + schema: SchemaContent, + prefix = '' +): { path: string; type: string }[] { + const fields: { path: string; type: string }[] = []; const properties = schema.properties; if (!properties) { - return paths; + return fields; } for (const [key, value] of Object.entries(properties)) { const currentPath = prefix ? `${prefix}.${key}` : key; - paths.push(currentPath); if (typeof value === 'object' && value !== null) { const propSchema = value as SchemaContent; + fields.push({ path: currentPath, type: propSchema.type || 'unknown' }); if (propSchema.type === 'object' && propSchema.properties) { - paths.push(...getAllFieldPaths(propSchema, currentPath)); + fields.push(...getAllFieldPathsTyped(propSchema, currentPath)); } if (propSchema.type === 'array' && propSchema.items) { const items = propSchema.items as SchemaContent; if (items.type === 'object' && items.properties) { - paths.push(...getAllFieldPaths(items, `${currentPath}[0]`)); + fields.push(...getAllFieldPathsTyped(items, `${currentPath}[0]`)); } } } } - return paths; + return fields; } export function parseExpressions( @@ -146,10 +175,10 @@ export function parseExpressions( function extractExpressionsFromString(str: string, paramPath: string): ExpressionRef[] { const refs: ExpressionRef[] = []; - // Limit field path length to prevent ReDoS attacks - const simplePattern = /\{\{\s*\$json\.([a-zA-Z0-9_.\[\]'"-]{1,200})\s*\}\}/g; + // Match every $json.field reference, even inside compound expressions like {{ $json.a || $json.b }} + const simplePattern = /\$json\.([a-zA-Z0-9_.\[\]'"-]{1,200})/g; const namedNodePattern = - /\{\{\s*\$\(['"]([^'"]{1,100})['"]\)\.item\.json\.([a-zA-Z0-9_.\[\]'"-]{1,200})\s*\}\}/g; + /\$\(['"]([^'"]{1,100})['"]\)\.item\.json\.([a-zA-Z0-9_.\[\]'"-]{1,200})/g; let match; @@ -163,7 +192,6 @@ function extractExpressionsFromString(str: string, paramPath: string): Expressio }); } - // Named node refs: $('Node Name').item.json.field (captures field part only for now) while ((match = namedNodePattern.exec(str)) !== null) { const field = match[2]; refs.push({ diff --git a/src/utils/workflow.ts b/src/utils/workflow.ts index da41081..b5dcfc1 100644 --- a/src/utils/workflow.ts +++ b/src/utils/workflow.ts @@ -7,11 +7,17 @@ import type { import { getNodeDefinition } from './catalog'; import { loadOutputSchema, + loadTriggerOutputSchema, parseExpressions, fieldExistsInSchema, - getAllFieldPaths, + getAllFieldPathsTyped, } from './outputSchema'; +function isTriggerNode(type: string): boolean { + const t = type.toLowerCase(); + return t.includes('trigger') || t.includes('webhook'); +} + export function validateWorkflow(workflow: N8nWorkflow): WorkflowValidationResult { const errors: string[] = []; const warnings: string[] = []; @@ -103,10 +109,7 @@ export function validateWorkflow(workflow: N8nWorkflow): WorkflowValidationResul // 5. Check for at least one trigger node const hasTrigger = workflow.nodes.some( - (node) => - node.type.toLowerCase().includes('trigger') || - node.type.toLowerCase().includes('webhook') || - node.name.toLowerCase().includes('start') + (node) => isTriggerNode(node.type) || node.name.toLowerCase().includes('start') ); if (!hasTrigger) { @@ -126,12 +129,11 @@ export function validateWorkflow(workflow: N8nWorkflow): WorkflowValidationResul } for (const node of workflow.nodes) { - const isTrigger = - node.type.toLowerCase().includes('trigger') || - node.type.toLowerCase().includes('webhook') || - node.name.toLowerCase().includes('start'); - - if (!isTrigger && !nodesWithIncoming.has(node.name)) { + if ( + !isTriggerNode(node.type) && + !node.name.toLowerCase().includes('start') && + !nodesWithIncoming.has(node.name) + ) { warnings.push(`Node "${node.name}" has no incoming connections - it will never execute`); } } @@ -243,14 +245,9 @@ export function validateNodeInputs(workflow: N8nWorkflow): string[] { continue; } - const isTrigger = - node.type.toLowerCase().includes('trigger') || - node.type.toLowerCase().includes('webhook') || - nodeDef.group.includes('trigger'); - - if (isTrigger) { + if (isTriggerNode(node.type) || nodeDef.group.includes('trigger')) { continue; - } // Triggers don't need incoming connections + } const expectedInputs = nodeDef.inputs.filter((i) => i === 'main').length; const actualInputs = incomingCount.get(node.name) || 0; @@ -294,6 +291,21 @@ export function positionNodes(workflow: N8nWorkflow): N8nWorkflow { return positioned; } +/** Ensure trigger nodes use simplified output when available. */ +export function normalizeTriggerSimpleParam(workflow: N8nWorkflow): void { + for (const node of workflow.nodes) { + if (!isTriggerNode(node.type)) { + continue; + } + + const def = getNodeDefinition(node.type); + const hasSimple = def?.properties?.some((p: { name: string }) => p.name === 'simple'); + if (hasSimple) { + node.parameters = { ...node.parameters, simple: true }; + } + } +} + /** * Validates that $json expressions reference fields that exist in upstream node output schemas. * Returns a list of invalid references that need correction. @@ -327,7 +339,9 @@ export function validateOutputReferences(workflow: N8nWorkflow): OutputRefValida const resource = (sourceNode.parameters?.resource as string) || ''; const operation = (sourceNode.parameters?.operation as string) || ''; - const schemaResult = loadOutputSchema(sourceNode.type, resource, operation); + const schemaResult = isTriggerNode(sourceNode.type) + ? loadTriggerOutputSchema(sourceNode.type, sourceNode.parameters as Record) + : loadOutputSchema(sourceNode.type, resource, operation); if (!schemaResult) { continue; } @@ -343,7 +357,9 @@ export function validateOutputReferences(workflow: N8nWorkflow): OutputRefValida sourceNodeType: sourceNode.type, resource, operation, - availableFields: getAllFieldPaths(schemaResult.schema), + availableFields: getAllFieldPathsTyped(schemaResult.schema).map( + (f) => `${f.path} (${f.type})` + ), }); } } From f4eb83be0c96548109b2ff3728b35eac3f232e26 Mon Sep 17 00:00:00 2001 From: standujar Date: Mon, 9 Feb 2026 16:02:56 +0100 Subject: [PATCH 3/9] fix: node extraction and tests and ci --- .github/workflows/ci.yml | 8 ++-- __tests__/unit/catalog.test.ts | 78 +++++++++++++++++++++++++++++++++- src/actions/createWorkflow.ts | 14 ++---- src/utils/catalog.ts | 24 +++++++++-- 4 files changed, 105 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c48ab0..e1732bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - - run: bun run crawl-nodes + - run: bun run crawl:nodes - run: bun run test:unit test-integration: @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - - run: bun run crawl-nodes + - run: bun run crawl:nodes - run: bun run test:integration test-e2e: @@ -42,7 +42,7 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - - run: bun run crawl-nodes + - run: bun run crawl:nodes - run: bun run test:e2e build: @@ -53,5 +53,5 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - - run: bun run crawl-nodes + - run: bun run crawl:nodes - run: bun run build diff --git a/__tests__/unit/catalog.test.ts b/__tests__/unit/catalog.test.ts index d7602ee..d53cbb5 100644 --- a/__tests__/unit/catalog.test.ts +++ b/__tests__/unit/catalog.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { searchNodes } from '../../src/utils/catalog'; +import { searchNodes, filterNodesByIntegrationSupport } from '../../src/utils/catalog'; describe('searchNodes', () => { test('returns empty array for empty keywords', () => { @@ -100,4 +100,80 @@ describe('searchNodes', () => { const results = searchNodes(['data']); expect(results.length).toBeLessThanOrEqual(15); }); + + test('finds OpenAI node by keyword', () => { + const results = searchNodes(['openai']); + expect(results.length).toBeGreaterThan(0); + const openAiNode = results.find((r) => r.node.name === 'openAi'); + expect(openAiNode).toBeDefined(); + expect(openAiNode!.node.credentials).toContainEqual( + expect.objectContaining({ name: 'openAiApi' }) + ); + }); + + test('finds OpenAI node with AI-related keywords', () => { + const results = searchNodes(['ai', 'openai']); + const openAiNode = results.find((r) => r.node.name === 'openAi'); + expect(openAiNode).toBeDefined(); + }); +}); + +describe('filterNodesByIntegrationSupport', () => { + test('keeps nodes with supported credentials', () => { + const nodes = searchNodes(['gmail', 'openai'], 10); + const supported = new Set(['gmailOAuth2Api', 'openAiApi']); + const { remaining, removed } = filterNodesByIntegrationSupport(nodes, supported); + + // OpenAI and Gmail nodes should remain + const openAi = remaining.find((r) => r.node.name === 'openAi'); + expect(openAi).toBeDefined(); + expect(removed.find((r) => r.node.name === 'openAi')).toBeUndefined(); + }); + + test('removes nodes with unsupported credentials', () => { + const nodes = searchNodes(['openai'], 10); + const supported = new Set(); // nothing supported + const { remaining, removed } = filterNodesByIntegrationSupport(nodes, supported); + + const openAiRemoved = removed.find((r) => r.node.name === 'openAi'); + expect(openAiRemoved).toBeDefined(); + expect(remaining.find((r) => r.node.name === 'openAi')).toBeUndefined(); + }); + + test('keeps utility nodes without credentials', () => { + const nodes = searchNodes(['set', 'if'], 10); + const supported = new Set(); + const { remaining } = filterNodesByIntegrationSupport(nodes, supported); + + // Utility nodes (no creds) should always remain + const utilityNodes = remaining.filter((r) => !r.node.credentials?.length); + expect(utilityNodes.length).toBeGreaterThan(0); + }); + + test('openAiApi credential is recognized by bridge map', () => { + // Simulates what checkCredentialTypes does in the cloud bridge + const API_KEY_CRED_TYPES = new Set(['openAiApi']); + const OAUTH_PREFIXES = [ + 'gmail', + 'google', + 'gSuite', + 'youTube', + 'slack', + 'github', + 'linear', + 'notion', + 'twitter', + ]; + + const isSupported = (credType: string) => + API_KEY_CRED_TYPES.has(credType) || OAUTH_PREFIXES.some((p) => credType.startsWith(p)); + + // These should all be supported + expect(isSupported('openAiApi')).toBe(true); + expect(isSupported('gmailOAuth2Api')).toBe(true); + expect(isSupported('slackOAuth2Api')).toBe(true); + + // This should NOT be supported + expect(isSupported('hubspotOAuth2Api')).toBe(false); + }); }); diff --git a/src/actions/createWorkflow.ts b/src/actions/createWorkflow.ts index 826b763..3a31c6b 100644 --- a/src/actions/createWorkflow.ts +++ b/src/actions/createWorkflow.ts @@ -311,21 +311,13 @@ export const createWorkflowAction: Action & { } if (existingDraft) { - // Guard: if the draft was created by this same message, re-show preview instead of classifying. - // This prevents the multi-step agent from auto-confirming in the same turn. + // Guard: if the draft was created by this same message, return silently. + // No callback = no new output for the multi-step agent to process = it stops looping. if (existingDraft.originMessageId && existingDraft.originMessageId === message.id) { logger.info( { src: 'plugin:n8n-workflow:action:create' }, - 'Same message as draft origin — re-showing preview' + 'Same message as draft origin — skipping' ); - const text = await formatActionResponse( - runtime, - 'PREVIEW', - buildPreviewData(existingDraft.workflow) - ); - if (callback) { - await callback({ text, success: true }); - } return { success: true, data: { awaitingUserInput: true } }; } diff --git a/src/utils/catalog.ts b/src/utils/catalog.ts index e14323a..e4a65fb 100644 --- a/src/utils/catalog.ts +++ b/src/utils/catalog.ts @@ -29,8 +29,17 @@ export function getNodeDefinition(typeName: string): NodeDefinition | undefined }); } +/** Split a name into lowercase tokens on camelCase / dot / hyphen / underscore boundaries */ +function tokenize(name: string): string[] { + return name + .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase → words + .split(/[\s.\-_]+/) + .map((t) => t.toLowerCase()) + .filter(Boolean); +} + /** - * Scoring: exact name 10, partial name 5, category 3, description 2, word 1 + * Scoring: exact name 10, word-boundary 7, substring 3, category 3, description 2, word 1 */ export function searchNodes(keywords: string[], limit = 15): NodeSearchResult[] { if (keywords.length === 0) { @@ -48,6 +57,8 @@ export function searchNodes(keywords: string[], limit = 15): NodeSearchResult[] const nodeName = node.name.toLowerCase(); const nodeDisplayName = node.displayName.toLowerCase(); const nodeDescription = node.description?.toLowerCase() || ''; + const nameTokens = tokenize(node.name); + const displayTokens = tokenize(node.displayName); for (const keyword of normalizedKeywords) { if (nodeName === keyword || nodeDisplayName === keyword) { @@ -56,8 +67,15 @@ export function searchNodes(keywords: string[], limit = 15): NodeSearchResult[] continue; } - if (nodeName.includes(keyword) || nodeDisplayName.includes(keyword)) { - score += 5; + // Word-boundary match: keyword equals a token in the name + const isWordMatch = + nameTokens.some((t) => t === keyword) || displayTokens.some((t) => t === keyword); + + if (isWordMatch) { + score += 7; + matchReasons.push(`word match: "${keyword}"`); + } else if (nodeName.includes(keyword) || nodeDisplayName.includes(keyword)) { + score += 3; matchReasons.push(`name contains: "${keyword}"`); } From 0579a4697d96033eabc676c677d6940781eb75a8 Mon Sep 17 00:00:00 2001 From: standujar Date: Tue, 10 Feb 2026 16:55:17 +0100 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20post-generation=20pipeline=20?= =?UTF-8?q?=E2=80=94=20param=20correction,=20expression=20prefix,=20output?= =?UTF-8?q?=20schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a multi-phase post-generation pipeline that automatically fixes LLM-generated workflow issues before deployment: - Two-phase parameter name correction: deterministic fuzzy matching (≥0.6 ratio) then LLM fallback for complex restructuring (e.g. messages → responses fixedCollection) - Expression prefix: auto-add `=` before `{{ }}` values so n8n evaluates them - Output schema injection: crawl langchain node schemas from override file, inject into generation prompt so LLM writes correct field paths (e.g. output[0].content[0].text instead of choices[0].message.content) - Post-correction: validateOutputReferences + correctFieldReferences as safety net Pipeline order: normalizeTrigger → correctOptions → detectUnknown + correctParams → validateOutputRefs + correctFieldRefs → ensureExpressionPrefix → validate 260 tests passing (780 assertions). --- .../actions/createWorkflow.test.ts | 39 +- __tests__/unit/catalog.test.ts | 92 +++- __tests__/unit/generation.test.ts | 92 ++++ __tests__/unit/outputSchema.test.ts | 70 +++ __tests__/unit/workflow.test.ts | 483 ++++++++++++++++++ scripts/crawl-nodes.ts | 101 +++- scripts/crawl-schemas.ts | 29 +- scripts/langchain-output-schemas.json | 123 +++++ src/actions/createWorkflow.ts | 1 + src/prompts/index.ts | 4 + src/prompts/parameterCorrection.ts | 29 ++ src/prompts/workflowGeneration.ts | 5 +- src/services/n8n-workflow-service.ts | 84 ++- src/types/index.ts | 7 +- src/utils/catalog.ts | 92 +++- src/utils/generation.ts | 203 +++++++- src/utils/outputSchema.ts | 1 + src/utils/workflow.ts | 235 ++++++++- 18 files changed, 1621 insertions(+), 69 deletions(-) create mode 100644 scripts/langchain-output-schemas.json create mode 100644 src/prompts/parameterCorrection.ts diff --git a/__tests__/integration/actions/createWorkflow.test.ts b/__tests__/integration/actions/createWorkflow.test.ts index 2bfcfb9..8208185 100644 --- a/__tests__/integration/actions/createWorkflow.test.ts +++ b/__tests__/integration/actions/createWorkflow.test.ts @@ -332,8 +332,45 @@ describe('CREATE_N8N_WORKFLOW action', () => { const lastText = calls[calls.length - 1][0].text; expect(lastText).toContain('Modified Workflow'); // modified workflow name - // Should store updated draft in cache + // Should store updated draft in cache with originMessageId expect(runtime.setCache).toHaveBeenCalled(); + const setCacheCall = (runtime.setCache as any).mock.calls.find((c: unknown[]) => + (c[0] as string).startsWith('workflow_draft:') + ); + expect(setCacheCall).toBeDefined(); + const storedDraft = setCacheCall[1] as WorkflowDraft; + expect(storedDraft.originMessageId).toBe(message.id); + }); + + test('second call with same message.id after modify skips without callback (anti-loop)', async () => { + const draft = createDraftInCache(); + draft.originMessageId = 'msg-001'; + + const mockService = createMockService(); + const runtime = createMockRuntime({ + services: { [N8N_WORKFLOW_SERVICE_TYPE]: mockService }, + cache: { 'workflow_draft:user-001': draft }, + }); + + const message = createMockMessage({ content: { text: 'Oui' } }); + const callback = createMockCallback(); + + const result = await createWorkflowAction.handler( + runtime, + message, + createMockState(), + {}, + callback + ); + + expect(result?.success).toBe(true); + expect(result?.data).toEqual({ awaitingUserInput: true }); + // No service calls — skipped entirely + expect(mockService.modifyWorkflowDraft).not.toHaveBeenCalled(); + expect(mockService.generateWorkflowDraft).not.toHaveBeenCalled(); + expect(mockService.deployWorkflow).not.toHaveBeenCalled(); + // No callback — agent gets no output to loop on + expect((callback as any).mock.calls.length).toBe(0); }); test('expired draft is cleared and treated as new', async () => { diff --git a/__tests__/unit/catalog.test.ts b/__tests__/unit/catalog.test.ts index d53cbb5..8477508 100644 --- a/__tests__/unit/catalog.test.ts +++ b/__tests__/unit/catalog.test.ts @@ -1,5 +1,10 @@ import { describe, test, expect } from 'bun:test'; -import { searchNodes, filterNodesByIntegrationSupport } from '../../src/utils/catalog'; +import { + searchNodes, + filterNodesByIntegrationSupport, + simplifyNodeForLLM, + getNodeDefinition, +} from '../../src/utils/catalog'; describe('searchNodes', () => { test('returns empty array for empty keywords', () => { @@ -104,7 +109,7 @@ describe('searchNodes', () => { test('finds OpenAI node by keyword', () => { const results = searchNodes(['openai']); expect(results.length).toBeGreaterThan(0); - const openAiNode = results.find((r) => r.node.name === 'openAi'); + const openAiNode = results.find((r) => r.node.name === '@n8n/n8n-nodes-langchain.openAi'); expect(openAiNode).toBeDefined(); expect(openAiNode!.node.credentials).toContainEqual( expect.objectContaining({ name: 'openAiApi' }) @@ -113,7 +118,7 @@ describe('searchNodes', () => { test('finds OpenAI node with AI-related keywords', () => { const results = searchNodes(['ai', 'openai']); - const openAiNode = results.find((r) => r.node.name === 'openAi'); + const openAiNode = results.find((r) => r.node.name === '@n8n/n8n-nodes-langchain.openAi'); expect(openAiNode).toBeDefined(); }); }); @@ -125,9 +130,9 @@ describe('filterNodesByIntegrationSupport', () => { const { remaining, removed } = filterNodesByIntegrationSupport(nodes, supported); // OpenAI and Gmail nodes should remain - const openAi = remaining.find((r) => r.node.name === 'openAi'); + const openAi = remaining.find((r) => r.node.name === '@n8n/n8n-nodes-langchain.openAi'); expect(openAi).toBeDefined(); - expect(removed.find((r) => r.node.name === 'openAi')).toBeUndefined(); + expect(removed.find((r) => r.node.name === '@n8n/n8n-nodes-langchain.openAi')).toBeUndefined(); }); test('removes nodes with unsupported credentials', () => { @@ -135,9 +140,11 @@ describe('filterNodesByIntegrationSupport', () => { const supported = new Set(); // nothing supported const { remaining, removed } = filterNodesByIntegrationSupport(nodes, supported); - const openAiRemoved = removed.find((r) => r.node.name === 'openAi'); + const openAiRemoved = removed.find((r) => r.node.name === '@n8n/n8n-nodes-langchain.openAi'); expect(openAiRemoved).toBeDefined(); - expect(remaining.find((r) => r.node.name === 'openAi')).toBeUndefined(); + expect( + remaining.find((r) => r.node.name === '@n8n/n8n-nodes-langchain.openAi') + ).toBeUndefined(); }); test('keeps utility nodes without credentials', () => { @@ -177,3 +184,74 @@ describe('filterNodesByIntegrationSupport', () => { expect(isSupported('hubspotOAuth2Api')).toBe(false); }); }); + +describe('simplifyNodeForLLM', () => { + test('strips notice and hidden properties', () => { + const openai = getNodeDefinition('@n8n/n8n-nodes-langchain.openAi'); + expect(openai).toBeDefined(); + + const hasNotice = openai!.properties.some((p) => p.type === 'notice'); + const hasHidden = openai!.properties.some((p) => p.type === 'hidden'); + expect(hasNotice || hasHidden).toBe(true); + + const simplified = simplifyNodeForLLM(openai!); + expect(simplified.properties.every((p) => p.type !== 'notice')).toBe(true); + expect(simplified.properties.every((p) => p.type !== 'hidden')).toBe(true); + }); + + test('removes routing and displayOptions from properties', () => { + const openai = getNodeDefinition('@n8n/n8n-nodes-langchain.openAi'); + const simplified = simplifyNodeForLLM(openai!); + + for (const prop of simplified.properties) { + const raw = prop as unknown as Record; + expect(raw.routing).toBeUndefined(); + expect(raw.displayOptions).toBeUndefined(); + expect(raw.typeOptions).toBeUndefined(); + expect(raw.modes).toBeUndefined(); + } + }); + + test('converts resourceLocator to string type', () => { + const openai = getNodeDefinition('@n8n/n8n-nodes-langchain.openAi'); + const hasResourceLocator = openai!.properties.some((p) => p.type === 'resourceLocator'); + expect(hasResourceLocator).toBe(true); + + const simplified = simplifyNodeForLLM(openai!); + expect(simplified.properties.every((p) => p.type !== 'resourceLocator')).toBe(true); + }); + + test('reduces JSON size significantly for complex nodes', () => { + const openai = getNodeDefinition('@n8n/n8n-nodes-langchain.openAi'); + const simplified = simplifyNodeForLLM(openai!); + + const originalSize = JSON.stringify(openai!.properties).length; + const simplifiedSize = JSON.stringify(simplified.properties).length; + expect(simplifiedSize).toBeLessThan(originalSize * 0.7); + }); + + test('preserves required fields and name/type/default', () => { + const openai = getNodeDefinition('@n8n/n8n-nodes-langchain.openAi'); + const simplified = simplifyNodeForLLM(openai!); + + for (const prop of simplified.properties) { + expect(prop.name).toBeDefined(); + expect(prop.displayName).toBeDefined(); + expect(prop.type).toBeDefined(); + expect('default' in prop).toBe(true); + } + + const resource = simplified.properties.find((p) => p.name === 'resource'); + expect(resource).toBeDefined(); + expect(resource!.options).toBeDefined(); + }); + + test('works on simple nodes without crashing', () => { + const setNode = getNodeDefinition('n8n-nodes-base.set'); + expect(setNode).toBeDefined(); + + const simplified = simplifyNodeForLLM(setNode!); + expect(simplified.properties.length).toBeGreaterThan(0); + expect(simplified.name).toBe(setNode!.name); + }); +}); diff --git a/__tests__/unit/generation.test.ts b/__tests__/unit/generation.test.ts index 82ee895..3d0da48 100644 --- a/__tests__/unit/generation.test.ts +++ b/__tests__/unit/generation.test.ts @@ -271,6 +271,98 @@ describe('generateWorkflow', () => { expect(params.prompt).toContain('Gmail'); expect(params.prompt).toContain('Send email'); }); + + test('injects output schema context for nodes with schemas', async () => { + const useModel = mock(() => + Promise.resolve( + JSON.stringify({ + name: 'WF', + nodes: [{ name: 'A', type: 't', position: [0, 0] }], + connections: {}, + }) + ) + ); + const runtime = createMockRuntime({ useModel }); + + // Gmail has schemas in schemaIndex.json + const nodes = [ + { + name: 'n8n-nodes-base.gmail', + displayName: 'Gmail', + description: 'Send email', + group: ['output'], + properties: [], + }, + ]; + + await generateWorkflow(runtime, 'get emails', nodes as any); + + const callArgs = useModel.mock.calls[0] as any[]; + const params = callArgs[1] as { prompt: string }; + expect(params.prompt).toContain('Node Output Schemas'); + expect(params.prompt).toContain('n8n-nodes-base.gmail'); + }); + + test('injects langchain OpenAI output schema with correct fields', async () => { + const useModel = mock(() => + Promise.resolve( + JSON.stringify({ + name: 'WF', + nodes: [{ name: 'A', type: 't', position: [0, 0] }], + connections: {}, + }) + ) + ); + const runtime = createMockRuntime({ useModel }); + + const nodes = [ + { + name: '@n8n/n8n-nodes-langchain.openAi', + displayName: 'OpenAI', + description: 'AI', + group: ['transform'], + properties: [], + }, + ]; + + await generateWorkflow(runtime, 'summarize with AI', nodes as any); + + const callArgs = useModel.mock.calls[0] as any[]; + const params = callArgs[1] as { prompt: string }; + expect(params.prompt).toContain('Node Output Schemas'); + expect(params.prompt).toContain('output[0].content[0].text: string'); + expect(params.prompt).toContain('Do NOT invent field names'); + }); + + test('no output schema section when nodes have no schemas', async () => { + const useModel = mock(() => + Promise.resolve( + JSON.stringify({ + name: 'WF', + nodes: [{ name: 'A', type: 't', position: [0, 0] }], + connections: {}, + }) + ) + ); + const runtime = createMockRuntime({ useModel }); + + // Unknown node with no schema + const nodes = [ + { + name: 'n8n-nodes-base.unknownNode', + displayName: 'Unknown', + description: 'No schema', + group: ['transform'], + properties: [], + }, + ]; + + await generateWorkflow(runtime, 'do something', nodes as any); + + const callArgs = useModel.mock.calls[0] as any[]; + const params = callArgs[1] as { prompt: string }; + expect(params.prompt).not.toContain('Node Output Schemas'); + }); }); // ============================================================================ diff --git a/__tests__/unit/outputSchema.test.ts b/__tests__/unit/outputSchema.test.ts index 18bea4e..7c228a7 100644 --- a/__tests__/unit/outputSchema.test.ts +++ b/__tests__/unit/outputSchema.test.ts @@ -5,6 +5,8 @@ import { loadTriggerOutputSchema, getTopLevelFields, getAllFieldPaths, + getAvailableResources, + getAvailableOperations, parseExpressions, fieldExistsInSchema, formatSchemaForPrompt, @@ -48,6 +50,10 @@ describe('hasOutputSchema', () => { test('returns false for unknown node', () => { expect(hasOutputSchema('n8n-nodes-base.unknownNode')).toBe(false); }); + + test('returns true for langchain OpenAI node', () => { + expect(hasOutputSchema('@n8n/n8n-nodes-langchain.openAi')).toBe(true); + }); }); describe('loadOutputSchema', () => { @@ -265,3 +271,67 @@ describe('loadTriggerOutputSchema', () => { expect(result).not.toBeNull(); }); }); + +// ============================================================================ +// Langchain OpenAI output schemas (from override file) +// ============================================================================ + +describe('langchain OpenAI schemas', () => { + test('loads text/response schema with correct output structure', () => { + const result = loadOutputSchema('@n8n/n8n-nodes-langchain.openAi', 'text', 'response'); + expect(result).not.toBeNull(); + expect(result!.fields).toContain('output'); + // Verify nested structure: output[0].content[0].text exists + expect(fieldExistsInSchema(['output', '0', 'content', '0', 'text'], result!.schema)).toBe(true); + }); + + test('detects wrong field path choices[0].message.content', () => { + const result = loadOutputSchema('@n8n/n8n-nodes-langchain.openAi', 'text', 'response'); + expect(result).not.toBeNull(); + // Old Completions API path should NOT exist + expect(fieldExistsInSchema(['choices', '0', 'message', 'content'], result!.schema)).toBe(false); + }); + + test('loads text/classify schema', () => { + const result = loadOutputSchema('@n8n/n8n-nodes-langchain.openAi', 'text', 'classify'); + expect(result).not.toBeNull(); + expect(result!.fields).toContain('flagged'); + }); + + test('loads image/generate schema', () => { + const result = loadOutputSchema('@n8n/n8n-nodes-langchain.openAi', 'image', 'generate'); + expect(result).not.toBeNull(); + expect(result!.fields).toContain('url'); + expect(result!.fields).toContain('revised_prompt'); + }); + + test('loads audio/transcribe schema', () => { + const result = loadOutputSchema('@n8n/n8n-nodes-langchain.openAi', 'audio', 'transcribe'); + expect(result).not.toBeNull(); + expect(result!.fields).toContain('text'); + expect(result!.fields).toContain('language'); + }); + + test('lists available resources', () => { + const resources = getAvailableResources('@n8n/n8n-nodes-langchain.openAi'); + expect(resources).toContain('text'); + expect(resources).toContain('image'); + expect(resources).toContain('audio'); + expect(resources).toContain('file'); + }); + + test('lists available operations for text resource', () => { + const ops = getAvailableOperations('@n8n/n8n-nodes-langchain.openAi', 'text'); + expect(ops).toContain('response'); + expect(ops).toContain('classify'); + }); + + test('formatSchemaForPrompt shows correct field paths for text/response', () => { + const result = loadOutputSchema('@n8n/n8n-nodes-langchain.openAi', 'text', 'response'); + expect(result).not.toBeNull(); + const formatted = formatSchemaForPrompt(result!.schema); + expect(formatted).toContain('output: array of objects'); + expect(formatted).toContain('output[0].content: array of objects'); + expect(formatted).toContain('output[0].content[0].text: string'); + }); +}); diff --git a/__tests__/unit/workflow.test.ts b/__tests__/unit/workflow.test.ts index 12ec5a7..fd91915 100644 --- a/__tests__/unit/workflow.test.ts +++ b/__tests__/unit/workflow.test.ts @@ -5,6 +5,9 @@ import { validateOutputReferences, validateNodeParameters, validateNodeInputs, + correctOptionParameters, + detectUnknownParameters, + ensureExpressionPrefix, } from '../../src/utils/workflow'; import { createValidWorkflow, @@ -405,6 +408,92 @@ describe('validateOutputReferences', () => { const refs = validateOutputReferences(workflow); expect(refs).toEqual([]); }); + + test('resolves $("NodeName") to correct source node schema', () => { + // Chain: Gmail Trigger → Gmail (getAll) → Slack + // Slack references Gmail Trigger via $('Gmail Trigger').item.json.Subject (valid) + // and Gmail via $json.subject (direct upstream, also valid) + const workflow = { + name: 'Named ref test', + nodes: [ + createGmailTriggerNode(), + createGmailNode({ parameters: { resource: 'message', operation: 'getAll' } }), + createSlackNode({ + parameters: { + text: "={{ $('Gmail Trigger').item.json.Subject }} - {{ $json.subject }}", + }, + }), + ], + connections: { + 'Gmail Trigger': { + main: [[{ node: 'Gmail', type: 'main', index: 0 }]], + }, + Gmail: { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + // Both should be valid — Subject exists in Gmail Trigger schema, subject exists in Gmail getAll schema + expect(refs).toEqual([]); + }); + + test('detects invalid field on named node ref', () => { + // Chain: Gmail Trigger → Gmail (getAll) → Slack + // Slack uses $('Gmail Trigger').item.json.nonExistentField — should be invalid + const workflow = { + name: 'Bad named ref', + nodes: [ + createGmailTriggerNode(), + createGmailNode({ parameters: { resource: 'message', operation: 'getAll' } }), + createSlackNode({ + parameters: { + text: "={{ $('Gmail Trigger').item.json.nonExistentField }}", + }, + }), + ], + connections: { + 'Gmail Trigger': { + main: [[{ node: 'Gmail', type: 'main', index: 0 }]], + }, + Gmail: { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + expect(refs.length).toBe(1); + expect(refs[0].field).toBe('nonExistentField'); + expect(refs[0].sourceNodeName).toBe('Gmail Trigger'); + }); + + test('named ref uses named node schema, not direct upstream', () => { + // Chain: Gmail Trigger → Gmail (getAll) → Slack + // Slack uses $('Gmail Trigger').item.json.From — valid in trigger schema + // Without the fix, this would validate against Gmail (getAll) schema + const workflow = { + name: 'Cross-node ref', + nodes: [ + createGmailTriggerNode(), + createGmailNode({ parameters: { resource: 'message', operation: 'getAll' } }), + createSlackNode({ + parameters: { + text: "={{ $('Gmail Trigger').item.json.From }}", + }, + }), + ], + connections: { + 'Gmail Trigger': { + main: [[{ node: 'Gmail', type: 'main', index: 0 }]], + }, + Gmail: { + main: [[{ node: 'Slack', type: 'main', index: 0 }]], + }, + }, + }; + const refs = validateOutputReferences(workflow); + expect(refs).toEqual([]); + }); }); // ============================================================================ @@ -469,3 +558,397 @@ describe('validateNodeInputs', () => { expect(warnings).toEqual([]); }); }); + +// ============================================================================ +// correctOptionParameters +// ============================================================================ + +describe('correctOptionParameters', () => { + test('corrects invalid resource and cascading operation (OpenAI chat→text)', () => { + const workflow = { + name: 'OpenAI Test', + nodes: [ + createTriggerNode(), + { + name: 'OpenAI', + type: '@n8n/n8n-nodes-langchain.openAi', + typeVersion: 1.8, + position: [500, 300] as [number, number], + parameters: { resource: 'chat', operation: 'message' }, + }, + ], + connections: { + 'Schedule Trigger': { + main: [[{ node: 'OpenAI', type: 'main', index: 0 }]], + }, + }, + }; + const fixes = correctOptionParameters(workflow); + expect(fixes).toBeGreaterThanOrEqual(2); // typeVersion + resource (+ possibly operation) + const openai = workflow.nodes[1]; + expect(openai.typeVersion).toBe(2.1); + expect(openai.parameters.resource).toBe('text'); + expect(openai.parameters.operation).toBe('response'); + }); + + test('does not touch valid parameters', () => { + const workflow = { + name: 'Valid Gmail', + nodes: [createTriggerNode(), createGmailNode()], + connections: { + 'Schedule Trigger': { + main: [[{ node: 'Gmail', type: 'main', index: 0 }]], + }, + }, + }; + const fixes = correctOptionParameters(workflow); + expect(fixes).toBe(0); + expect(workflow.nodes[1].parameters.resource).toBe('message'); + expect(workflow.nodes[1].parameters.operation).toBe('send'); + }); + + test('corrects typeVersion when not in catalog version list', () => { + const workflow = { + name: 'Bad Version', + nodes: [ + { + name: 'OpenAI', + type: '@n8n/n8n-nodes-langchain.openAi', + typeVersion: 1.4, + position: [250, 300] as [number, number], + parameters: { resource: 'text', operation: 'response' }, + }, + ], + connections: {}, + }; + const fixes = correctOptionParameters(workflow); + expect(fixes).toBeGreaterThanOrEqual(1); + expect(workflow.nodes[0].typeVersion).toBe(2.1); + }); + + test('skips unknown node types', () => { + const workflow = { + name: 'Unknown', + nodes: [ + { + name: 'Custom', + type: 'n8n-nodes-community.unknown', + typeVersion: 1, + position: [250, 300] as [number, number], + parameters: { resource: 'whatever' }, + }, + ], + connections: {}, + }; + const fixes = correctOptionParameters(workflow); + expect(fixes).toBe(0); + }); + + test('corrects wrong node type prefix (n8n-nodes-base.openAi → langchain)', () => { + const workflow = { + name: 'Wrong Prefix', + nodes: [ + { + name: 'OpenAI', + type: 'n8n-nodes-base.openAi', + typeVersion: 2.1, + position: [250, 300] as [number, number], + parameters: { resource: 'text', operation: 'response' }, + }, + ], + connections: {}, + }; + const fixes = correctOptionParameters(workflow); + expect(fixes).toBeGreaterThanOrEqual(1); + expect(workflow.nodes[0].type).toBe('@n8n/n8n-nodes-langchain.openAi'); + }); + + test('skips dependent options not visible for current resource', () => { + const workflow = { + name: 'OpenAI Image', + nodes: [ + { + name: 'OpenAI', + type: '@n8n/n8n-nodes-langchain.openAi', + typeVersion: 2.1, + position: [250, 300] as [number, number], + parameters: { resource: 'image', operation: 'generate' }, + }, + ], + connections: {}, + }; + const fixes = correctOptionParameters(workflow); + expect(fixes).toBe(0); + expect(workflow.nodes[0].parameters.operation).toBe('generate'); + }); +}); + +// ============================================================================ +// detectUnknownParameters +// ============================================================================ + +describe('detectUnknownParameters', () => { + test('detects unknown params on OpenAI node (model → modelId)', () => { + const workflow = { + name: 'OpenAI Bad Params', + nodes: [ + { + name: 'OpenAI', + type: '@n8n/n8n-nodes-langchain.openAi', + typeVersion: 2.1, + position: [250, 300] as [number, number], + parameters: { + resource: 'text', + operation: 'response', + model: 'gpt-4o-mini', + prompt: 'Hello world', + }, + }, + ], + connections: {}, + }; + const detections = detectUnknownParameters(workflow); + expect(detections.length).toBe(1); + expect(detections[0].nodeName).toBe('OpenAI'); + expect(detections[0].unknownKeys).toContain('model'); + expect(detections[0].unknownKeys).toContain('prompt'); + // resource and operation are valid, should NOT be in unknownKeys + expect(detections[0].unknownKeys).not.toContain('resource'); + expect(detections[0].unknownKeys).not.toContain('operation'); + }); + + test('returns empty for node with valid params', () => { + const workflow = { + name: 'Gmail Valid', + nodes: [ + { + name: 'Gmail', + type: 'n8n-nodes-base.gmail', + typeVersion: 2, + position: [250, 300] as [number, number], + parameters: { resource: 'message', operation: 'send' }, + }, + ], + connections: {}, + }; + const detections = detectUnknownParameters(workflow); + expect(detections.length).toBe(0); + }); + + test('skips unknown node types', () => { + const workflow = { + name: 'Unknown Type', + nodes: [ + { + name: 'Custom', + type: 'n8n-nodes-community.unknown', + typeVersion: 1, + position: [250, 300] as [number, number], + parameters: { anything: 'goes' }, + }, + ], + connections: {}, + }; + const detections = detectUnknownParameters(workflow); + expect(detections.length).toBe(0); + }); + + test('includes property definitions for LLM correction', () => { + const workflow = { + name: 'OpenAI Props', + nodes: [ + { + name: 'OpenAI', + type: '@n8n/n8n-nodes-langchain.openAi', + typeVersion: 2.1, + position: [250, 300] as [number, number], + parameters: { resource: 'text', operation: 'response', model: 'gpt-4o-mini' }, + }, + ], + connections: {}, + }; + const detections = detectUnknownParameters(workflow); + expect(detections.length).toBe(1); + // Should include simplified property definitions + expect(detections[0].propertyDefs.length).toBeGreaterThan(0); + // modelId should be in the visible definitions for resource: "text" + const hasModelId = detections[0].propertyDefs.some((p) => p.name === 'modelId'); + expect(hasModelId).toBe(true); + // model should NOT be visible for resource: "text" + const hasModel = detections[0].propertyDefs.some((p) => p.name === 'model'); + expect(hasModel).toBe(false); + }); + + test('handles multiple nodes, only flags ones with unknown params', () => { + const workflow = { + name: 'Mixed', + nodes: [ + { + name: 'Gmail', + type: 'n8n-nodes-base.gmail', + typeVersion: 2, + position: [250, 300] as [number, number], + parameters: { resource: 'message', operation: 'send' }, + }, + { + name: 'OpenAI', + type: '@n8n/n8n-nodes-langchain.openAi', + typeVersion: 2.1, + position: [500, 300] as [number, number], + parameters: { resource: 'text', model: 'gpt-4o' }, + }, + ], + connections: {}, + }; + const detections = detectUnknownParameters(workflow); + expect(detections.length).toBe(1); + expect(detections[0].nodeName).toBe('OpenAI'); + }); +}); + +// ============================================================================ +// ensureExpressionPrefix +// ============================================================================ + +describe('ensureExpressionPrefix', () => { + test('adds = prefix to {{ }} values', () => { + const workflow = { + name: 'Test', + nodes: [ + { + name: 'Gmail', + type: 'n8n-nodes-base.gmail', + typeVersion: 2, + position: [0, 0] as [number, number], + parameters: { + subject: '{{ $json.Subject }}', + to: 'fixed@example.com', + }, + }, + ], + connections: {}, + }; + const count = ensureExpressionPrefix(workflow); + expect(count).toBe(1); + expect(workflow.nodes[0].parameters.subject).toBe('={{ $json.Subject }}'); + expect(workflow.nodes[0].parameters.to).toBe('fixed@example.com'); + }); + + test('does not double-prefix values already starting with =', () => { + const workflow = { + name: 'Test', + nodes: [ + { + name: 'Gmail', + type: 'n8n-nodes-base.gmail', + typeVersion: 2, + position: [0, 0] as [number, number], + parameters: { + subject: '={{ $json.Subject }}', + }, + }, + ], + connections: {}, + }; + const count = ensureExpressionPrefix(workflow); + expect(count).toBe(0); + expect(workflow.nodes[0].parameters.subject).toBe('={{ $json.Subject }}'); + }); + + test('handles nested objects (fixedCollection values)', () => { + const workflow = { + name: 'Test', + nodes: [ + { + name: 'OpenAI', + type: '@n8n/n8n-nodes-langchain.openAi', + typeVersion: 2.1, + position: [0, 0] as [number, number], + parameters: { + responses: { + values: [{ content: '{{ $json.Subject }}' }], + }, + }, + }, + ], + connections: {}, + }; + const count = ensureExpressionPrefix(workflow); + expect(count).toBe(1); + expect((workflow.nodes[0].parameters.responses as any).values[0].content).toBe( + '={{ $json.Subject }}' + ); + }); + + test('handles multiple nodes and multiple values', () => { + const workflow = { + name: 'Test', + nodes: [ + { + name: 'Node1', + type: 'n8n-nodes-base.gmail', + typeVersion: 2, + position: [0, 0] as [number, number], + parameters: { + subject: '{{ $json.Subject }}', + body: '{{ $json.body }}', + }, + }, + { + name: 'Node2', + type: 'n8n-nodes-base.slack', + typeVersion: 2, + position: [200, 0] as [number, number], + parameters: { + text: '{{ $json.output[0].content[0].text }}', + channel: '#general', + }, + }, + ], + connections: {}, + }; + const count = ensureExpressionPrefix(workflow); + expect(count).toBe(3); + }); + + test('skips nodes without parameters', () => { + const workflow = { + name: 'Test', + nodes: [ + { + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: {}, + }, + ], + connections: {}, + }; + const count = ensureExpressionPrefix(workflow); + expect(count).toBe(0); + }); + + test('handles string values in arrays', () => { + const workflow = { + name: 'Test', + nodes: [ + { + name: 'Node', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: { + items: ['{{ $json.a }}', 'static', '{{ $json.b }}'], + }, + }, + ], + connections: {}, + }; + const count = ensureExpressionPrefix(workflow); + expect(count).toBe(2); + expect((workflow.nodes[0].parameters.items as string[])[0]).toBe('={{ $json.a }}'); + expect((workflow.nodes[0].parameters.items as string[])[1]).toBe('static'); + expect((workflow.nodes[0].parameters.items as string[])[2]).toBe('={{ $json.b }}'); + }); +}); diff --git a/scripts/crawl-nodes.ts b/scripts/crawl-nodes.ts index d15773f..6b78292 100644 --- a/scripts/crawl-nodes.ts +++ b/scripts/crawl-nodes.ts @@ -1,16 +1,19 @@ /** - * Extract all n8n node definitions from n8n-nodes-base and write to defaultNodes.json. + * Extract n8n node definitions from n8n-nodes-base and @n8n/n8n-nodes-langchain, + * then write to defaultNodes.json. * - * Uses the pre-compiled types/nodes.json shipped with n8n-nodes-base - * (no class instantiation needed — avoids missing peer-dependency issues). + * - n8n-nodes-base: installed as devDependency, read directly + * - n8n-nodes-langchain: downloaded as tarball (82+ deps — not installed), + * only the openAi node is extracted to replace the deprecated n8n-nodes-base version * - * Requires n8n-nodes-base as a devDependency. - * Run with: bun run crawl-nodes + * Run with: bun run crawl:nodes */ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { execSync } from 'node:child_process'; import path from 'node:path'; const OUTPUT = path.resolve(import.meta.dir, '..', 'src', 'data', 'defaultNodes.json'); +const TMP_DIR = path.join(import.meta.dir, '..', '.tmp-langchain'); const KEEP_KEYS = [ 'name', @@ -25,6 +28,73 @@ const KEEP_KEYS = [ 'documentationUrl', ] as const; +/** Langchain nodes that override their deprecated n8n-nodes-base counterpart. */ +const LANGCHAIN_OVERRIDES = ['openAi']; + +function filterKeys(node: Record): Record { + const filtered: Record = {}; + for (const key of KEEP_KEYS) { + if (node[key] !== undefined) { + filtered[key] = node[key]; + } + } + return filtered; +} + +async function loadLangchainNodes(): Promise>> { + const overrides = new Map>(); + + try { + await mkdir(TMP_DIR, { recursive: true }); + + console.log('Downloading @n8n/n8n-nodes-langchain tarball...'); + execSync('npm pack @n8n/n8n-nodes-langchain', { cwd: TMP_DIR, stdio: 'pipe' }); + + const tgzFile = execSync('ls *.tgz', { cwd: TMP_DIR, encoding: 'utf-8' }).trim().split('\n')[0]; + execSync(`tar -xf "${tgzFile}" package/dist/types/nodes.json`, { cwd: TMP_DIR, stdio: 'pipe' }); + + const raw = await readFile( + path.join(TMP_DIR, 'package', 'dist', 'types', 'nodes.json'), + 'utf-8' + ); + const allNodes: Record[] = JSON.parse(raw); + console.log(` Found ${allNodes.length} langchain node definitions`); + + for (const nodeName of LANGCHAIN_OVERRIDES) { + const candidates = allNodes.filter((n) => n.name === nodeName); + if (candidates.length === 0) { + console.warn(` Warning: ${nodeName} not found in langchain package`); + continue; + } + + const latest = candidates.reduce((best, cur) => { + const bv = Array.isArray(best.version) + ? Math.max(...(best.version as number[])) + : ((best.version as number) ?? 0); + const cv = Array.isArray(cur.version) + ? Math.max(...(cur.version as number[])) + : ((cur.version as number) ?? 0); + return cv > bv ? cur : best; + }); + + const prefixed = filterKeys(latest); + prefixed.name = `@n8n/n8n-nodes-langchain.${nodeName}`; + overrides.set(nodeName, prefixed); + console.log( + ` Extracted ${prefixed.name} v${Array.isArray(latest.version) ? (latest.version as number[]).join('/') : latest.version} from langchain` + ); + } + } catch (error) { + console.warn( + `Warning: failed to load langchain nodes (catalog will use n8n-nodes-base only): ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + await rm(TMP_DIR, { recursive: true, force: true }).catch(() => {}); + } + + return overrides; +} + async function main() { let nodesBasePath: string; try { @@ -34,14 +104,15 @@ async function main() { process.exit(1); } + const langchainOverrides = await loadLangchainNodes(); + const typesPath = path.join(nodesBasePath, '..', 'dist', 'types', 'nodes.json'); console.log(`Reading ${typesPath} ...`); const raw = await readFile(typesPath, 'utf-8'); const allNodes: Record[] = JSON.parse(raw); - console.log(`Found ${allNodes.length} node definitions`); + console.log(`Found ${allNodes.length} base node definitions`); - // Deduplicate by name (some nodes appear multiple times for different versions) const seen = new Set(); const nodes: Record[] = []; @@ -50,13 +121,15 @@ async function main() { if (!name || seen.has(name)) continue; seen.add(name); - const filtered: Record = {}; - for (const key of KEEP_KEYS) { - if (node[key] !== undefined) { - filtered[key] = node[key]; - } + const override = langchainOverrides.get(name); + if (override) { + nodes.push(override); + console.log(` Replaced ${name} with langchain version`); + } else { + const prefixed = filterKeys(node); + prefixed.name = `n8n-nodes-base.${name}`; + nodes.push(prefixed); } - nodes.push(filtered); } await mkdir(path.dirname(OUTPUT), { recursive: true }); diff --git a/scripts/crawl-schemas.ts b/scripts/crawl-schemas.ts index 14c4077..69e1523 100644 --- a/scripts/crawl-schemas.ts +++ b/scripts/crawl-schemas.ts @@ -1,5 +1,10 @@ /** - * Extract output schemas from n8n-nodes-base for expression validation. + * Extract output schemas from n8n-nodes-base and @n8n/n8n-nodes-langchain + * for expression validation. + * + * - n8n-nodes-base: scans __schema__/ dirs shipped with the package + * - n8n-nodes-langchain: no __schema__ dirs, so output schemas are defined + * manually based on the actual source code of each operation * * Embeds the full schema content (not just paths) so validation works * at runtime without needing n8n-nodes-base installed. @@ -10,6 +15,7 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; const OUTPUT = path.resolve(import.meta.dir, '..', 'src', 'data', 'schemaIndex.json'); +const LANGCHAIN_OVERRIDES = path.resolve(import.meta.dir, 'langchain-output-schemas.json'); // Full schema content embedded interface SchemaContent { @@ -165,6 +171,27 @@ async function main() { } } + // Merge langchain override schemas (no __schema__ dirs in that package) + try { + const overrideRaw = await readFile(LANGCHAIN_OVERRIDES, 'utf-8'); + const overrideData = JSON.parse(overrideRaw) as { nodes: Record }; + + for (const [nodeType, entry] of Object.entries(overrideData.nodes)) { + index.nodeTypes[nodeType] = entry; + const count = Object.values(entry.schemas).reduce( + (sum, ops) => sum + Object.keys(ops).length, + 0 + ); + nodesWithSchemas++; + totalSchemas += count; + console.log(` Added langchain override: ${nodeType} (${count} schemas)`); + } + } catch (error) { + console.warn( + `Warning: failed to load langchain overrides: ${error instanceof Error ? error.message : String(error)}` + ); + } + await mkdir(path.dirname(OUTPUT), { recursive: true }); await writeFile(OUTPUT, JSON.stringify(index, null, 2), 'utf-8'); diff --git a/scripts/langchain-output-schemas.json b/scripts/langchain-output-schemas.json new file mode 100644 index 0000000..8030fd0 --- /dev/null +++ b/scripts/langchain-output-schemas.json @@ -0,0 +1,123 @@ +{ + "_comment": "Output schemas for @n8n/n8n-nodes-langchain nodes (no __schema__/ dirs in package). Derived from source code. Merged into schemaIndex.json by crawl-schemas.ts.", + "_langchainVersion": "0.6.x", + "nodes": { + "@n8n/n8n-nodes-langchain.openAi": { + "folder": "vendors/OpenAi", + "schemas": { + "text": { + "response": { + "type": "object", + "properties": { + "output": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "text": { "type": "string" } + } + } + } + } + } + } + } + }, + "classify": { + "type": "object", + "properties": { + "flagged": { "type": "boolean" } + } + } + }, + "image": { + "generate": { + "type": "object", + "properties": { + "url": { "type": "string" }, + "revised_prompt": { "type": "string" } + } + }, + "analyze": { + "type": "object", + "properties": { + "output": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "text": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "audio": { + "transcribe": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "language": { "type": "string" } + } + }, + "translate": { + "type": "object", + "properties": { + "text": { "type": "string" } + } + } + }, + "file": { + "upload": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "object": { "type": "string" }, + "bytes": { "type": "integer" }, + "created_at": { "type": "integer" }, + "filename": { "type": "string" }, + "purpose": { "type": "string" } + } + }, + "list": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "object": { "type": "string" }, + "bytes": { "type": "integer" }, + "created_at": { "type": "integer" }, + "filename": { "type": "string" }, + "purpose": { "type": "string" } + } + }, + "deleteFile": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "object": { "type": "string" }, + "deleted": { "type": "boolean" } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/actions/createWorkflow.ts b/src/actions/createWorkflow.ts index 3a31c6b..1569e4e 100644 --- a/src/actions/createWorkflow.ts +++ b/src/actions/createWorkflow.ts @@ -402,6 +402,7 @@ export const createWorkflowAction: Action & { prompt: existingDraft.prompt, userId, createdAt: Date.now(), + originMessageId: message.id, }; await runtime.setCache(cacheKey, modifiedDraft); diff --git a/src/prompts/index.ts b/src/prompts/index.ts index fc4a52d..cecf9d3 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -4,3 +4,7 @@ export { DRAFT_INTENT_SYSTEM_PROMPT } from './draftIntent'; export { ACTION_RESPONSE_SYSTEM_PROMPT } from './actionResponse'; export { FEASIBILITY_CHECK_PROMPT } from './feasibilityCheck'; export { FIELD_CORRECTION_SYSTEM_PROMPT, FIELD_CORRECTION_USER_PROMPT } from './fieldCorrection'; +export { + PARAM_CORRECTION_SYSTEM_PROMPT, + PARAM_CORRECTION_USER_PROMPT, +} from './parameterCorrection'; diff --git a/src/prompts/parameterCorrection.ts b/src/prompts/parameterCorrection.ts new file mode 100644 index 0000000..0e2dade --- /dev/null +++ b/src/prompts/parameterCorrection.ts @@ -0,0 +1,29 @@ +export const PARAM_CORRECTION_SYSTEM_PROMPT = `Fix n8n node parameters to match the node's property definition. + +You receive: +1. The node type and its current parameters (generated by another LLM — may use wrong names or structure) +2. The node's valid property definitions from the catalog + +Your job: map the current parameter VALUES to the correct parameter NAMES and STRUCTURE from the definition. + +Rules: +- Use ONLY parameter names that exist in the property definitions +- Preserve the user's intent and values +- For "fixedCollection" type properties, values MUST be nested inside a "values" array of objects (see example below) +- Keep "resource" and "operation" values unchanged (already validated) +- If a parameter has no plausible match in the definitions, drop it +- Return ONLY a valid JSON object — no explanation, no markdown fences + +Example — fixedCollection correction: +WRONG: { "prompt": "Say hello" } +If the definition has a fixedCollection property "responses" with sub-property "content": +RIGHT: { "responses": { "values": [{ "content": "Say hello" }] } }`; + +export const PARAM_CORRECTION_USER_PROMPT = `Node: {nodeType} +Current parameters: +{currentParams} + +Property definitions: +{propertyDefs} + +Return corrected parameters JSON:`; diff --git a/src/prompts/workflowGeneration.ts b/src/prompts/workflowGeneration.ts index e0954ab..5011acc 100644 --- a/src/prompts/workflowGeneration.ts +++ b/src/prompts/workflowGeneration.ts @@ -144,8 +144,9 @@ If a service the user mentioned is not in the available nodes, do NOT include it Use \`_meta.assumptions\` to document when you used an alternative integration. **When creating nodes:** -- Use required parameters from the node's type definition. -- For options, pick the most common or user-specified value. +- **CRITICAL: Use EXACTLY the parameter names from each node's "properties" definitions.** Do NOT guess or use names from your training data. If the definition says \`modelId\`, use \`modelId\` — not \`model\`. If it says \`responses\`, use \`responses\` — not \`prompt\`. The exact \`name\` field in each property definition is what goes into \`parameters\`. +- For \`fixedCollection\` type properties, values MUST be nested inside a \`"values"\` array of objects. Example: \`"responses": { "values": [{ "content": "..." }] }\` +- For \`options\` type parameters, pick the most common or user-specified value from the property's \`options\` array. - Use unique names for each node. - Connect nodes using the \`connections\` object, with \`"main"\` as the default connection type. - For nodes requiring authentication, include the \`credentials\` field with the appropriate credential type. diff --git a/src/services/n8n-workflow-service.ts b/src/services/n8n-workflow-service.ts index 9c3f5db..229049b 100644 --- a/src/services/n8n-workflow-service.ts +++ b/src/services/n8n-workflow-service.ts @@ -9,6 +9,7 @@ import { collectExistingNodeDefinitions, assessFeasibility, correctFieldReferences, + correctParameterNames, } from '../utils/generation'; import { positionNodes, @@ -17,6 +18,9 @@ import { validateNodeInputs, validateOutputReferences, normalizeTriggerSimpleParam, + correctOptionParameters, + detectUnknownParameters, + ensureExpressionPrefix, } from '../utils/workflow'; import { resolveCredentials } from '../utils/credentialResolver'; import type { @@ -242,6 +246,24 @@ export class N8nWorkflowService extends Service { ); normalizeTriggerSimpleParam(workflow); + + const optionFixes = correctOptionParameters(workflow); + if (optionFixes > 0) { + logger.debug( + { src: 'plugin:n8n-workflow:service:main' }, + `Corrected ${optionFixes} invalid option parameter(s)` + ); + } + + const unknownParams = detectUnknownParameters(workflow); + if (unknownParams.length > 0) { + logger.debug( + { src: 'plugin:n8n-workflow:service:main' }, + `Found ${unknownParams.length} node(s) with unknown parameters, auto-correcting...` + ); + workflow = await correctParameterNames(this.runtime, workflow, unknownParams); + } + const invalidRefs = validateOutputReferences(workflow); if (invalidRefs.length > 0) { logger.debug( @@ -251,6 +273,14 @@ export class N8nWorkflowService extends Service { workflow = await correctFieldReferences(this.runtime, workflow, invalidRefs); } + const exprPrefixed = ensureExpressionPrefix(workflow); + if (exprPrefixed > 0) { + logger.debug( + { src: 'plugin:n8n-workflow:service:main' }, + `Prefixed ${exprPrefixed} expression value(s) with "="` + ); + } + const validationResult = validateWorkflow(workflow); if (!validationResult.valid) { logger.error( @@ -310,6 +340,24 @@ export class N8nWorkflowService extends Service { ); normalizeTriggerSimpleParam(workflow); + + const optionFixes = correctOptionParameters(workflow); + if (optionFixes > 0) { + logger.debug( + { src: 'plugin:n8n-workflow:service:main' }, + `Corrected ${optionFixes} invalid option parameter(s) in modified workflow` + ); + } + + const unknownParams = detectUnknownParameters(workflow); + if (unknownParams.length > 0) { + logger.debug( + { src: 'plugin:n8n-workflow:service:main' }, + `Found ${unknownParams.length} node(s) with unknown parameters in modified workflow, auto-correcting...` + ); + workflow = await correctParameterNames(this.runtime, workflow, unknownParams); + } + const invalidRefs = validateOutputReferences(workflow); if (invalidRefs.length > 0) { logger.debug( @@ -319,6 +367,14 @@ export class N8nWorkflowService extends Service { workflow = await correctFieldReferences(this.runtime, workflow, invalidRefs); } + const exprPrefixed = ensureExpressionPrefix(workflow); + if (exprPrefixed > 0) { + logger.debug( + { src: 'plugin:n8n-workflow:service:main' }, + `Prefixed ${exprPrefixed} expression value(s) with "=" in modified workflow` + ); + } + const validationResult = validateWorkflow(workflow); if (!validationResult.valid) { logger.error( @@ -372,15 +428,29 @@ export class N8nWorkflowService extends Service { }; } - // Determine if this is an update (existing workflow) or create (new workflow) - const isUpdate = !!workflow.id; - const deployedWorkflow = isUpdate - ? await client.updateWorkflow(workflow.id!, credentialResult.workflow) - : await client.createWorkflow(credentialResult.workflow); + // Determine if this is an update (existing workflow) or create (new workflow). + // If update fails (workflow deleted on n8n), fallback to create. + let deployedWorkflow; + let wasUpdate = false; + if (workflow.id) { + try { + deployedWorkflow = await client.updateWorkflow(workflow.id, credentialResult.workflow); + wasUpdate = true; + } catch { + logger.warn( + { src: 'plugin:n8n-workflow:service:main' }, + `Update failed for workflow ${workflow.id}, creating new workflow instead` + ); + const { id: _, ...rest } = credentialResult.workflow as unknown as Record; + deployedWorkflow = await client.createWorkflow(rest as unknown as N8nWorkflow); + } + } else { + deployedWorkflow = await client.createWorkflow(credentialResult.workflow); + } logger.info( { src: 'plugin:n8n-workflow:service:main' }, - `Workflow ${isUpdate ? 'updated' : 'created'}: ${deployedWorkflow.id}` + `Workflow ${wasUpdate ? 'updated' : 'created'}: ${deployedWorkflow.id}` ); // Activate (publish) the workflow immediately after creation/update @@ -400,7 +470,7 @@ export class N8nWorkflowService extends Service { } // Only tag new workflows (existing ones should already have tags) - if (userId && !isUpdate) { + if (userId && !wasUpdate) { try { const userTag = await client.getOrCreateTag(tagName); await client.updateWorkflowTags(deployedWorkflow.id, [userTag.id]); diff --git a/src/types/index.ts b/src/types/index.ts index bb2ef27..43dbd16 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -149,14 +149,14 @@ export interface NodeDefinition { icon?: string; iconUrl?: string; group: string[]; - version: number; + version: number | number[]; subtitle?: string; defaults: { name: string; color?: string; }; - inputs: string[]; - outputs: string[]; + inputs: string[] | string; + outputs: string[] | string; credentials?: Array<{ name: string; required: boolean; @@ -367,6 +367,7 @@ export interface ExpressionRef { field: string; // "sender" or "from.value[0].address" path: string[]; // ["sender"] or ["from", "value", "0", "address"] paramPath: string; // Parameter path where expression was found + sourceNodeName?: string; // Node name from $('NodeName') syntax, undefined for $json refs } /** diff --git a/src/utils/catalog.ts b/src/utils/catalog.ts index e4a65fb..f7a3313 100644 --- a/src/utils/catalog.ts +++ b/src/utils/catalog.ts @@ -1,4 +1,9 @@ -import { NodeDefinition, NodeSearchResult, IntegrationFilterResult } from '../types/index'; +import { + NodeDefinition, + NodeProperty, + NodeSearchResult, + IntegrationFilterResult, +} from '../types/index'; import defaultNodesData from '../data/defaultNodes.json' assert { type: 'json' }; /** @@ -10,30 +15,29 @@ import defaultNodesData from '../data/defaultNodes.json' assert { type: 'json' } const NODE_CATALOG = defaultNodesData as NodeDefinition[]; /** - * Look up a node definition by its type name + * Look up a node definition by its type name. * - * Handles both full names ("n8n-nodes-base.gmail") and bare names ("gmail"). + * Handles full names ("n8n-nodes-base.gmail", "@n8n/n8n-nodes-langchain.openAi") + * and bare names ("gmail", "openAi"). */ export function getNodeDefinition(typeName: string): NodeDefinition | undefined { - // Try exact match first const exact = NODE_CATALOG.find((n) => n.name === typeName); if (exact) { return exact; } - // Try without prefix (e.g., "gmail" matches "n8n-nodes-base.gmail") - const bare = typeName.replace(/^n8n-nodes-base\./, ''); + const bare = typeName.replace(/^(?:n8n-nodes-base|@n8n\/n8n-nodes-langchain)\./, ''); return NODE_CATALOG.find((n) => { - const catalogBare = n.name.replace(/^n8n-nodes-base\./, ''); + const catalogBare = n.name.replace(/^(?:n8n-nodes-base|@n8n\/n8n-nodes-langchain)\./, ''); return catalogBare === bare || n.name === bare; }); } -/** Split a name into lowercase tokens on camelCase / dot / hyphen / underscore boundaries */ +/** Split a name into lowercase tokens on camelCase / dot / hyphen / underscore / @ / slash boundaries */ function tokenize(name: string): string[] { return name .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase → words - .split(/[\s.\-_]+/) + .split(/[\s.\-_@/]+/) .map((t) => t.toLowerCase()) .filter(Boolean); } @@ -135,3 +139,73 @@ export function filterNodesByIntegrationSupport( return { remaining, removed }; } + +const NOISE_TYPES = new Set(['notice', 'hidden']); +const STRIP_KEYS = new Set([ + 'routing', + 'displayOptions', + 'typeOptions', + 'hint', + 'isNodeSetting', + 'noDataExpression', + 'validateType', + 'ignoreValidationDuringExecution', + 'requiresDataPath', + 'disabledOptions', + 'credentialTypes', + 'modes', +]); + +function simplifyProperty(prop: NodeProperty): NodeProperty | null { + if (NOISE_TYPES.has(prop.type)) { + return null; + } + + const slim: Record = {}; + for (const [key, value] of Object.entries(prop)) { + if (STRIP_KEYS.has(key)) { + continue; + } + slim[key] = value; + } + + if (prop.type === 'resourceLocator') { + slim.type = 'string'; + slim.default = ''; + slim.description = slim.description || `${prop.displayName} ID`; + } + + if (prop.options && Array.isArray(prop.options)) { + slim.options = prop.options.map((opt: Record) => { + if (opt.values && Array.isArray(opt.values)) { + return { + name: opt.name, + displayName: opt.displayName, + values: (opt.values as NodeProperty[]) + .map(simplifyProperty) + .filter((v): v is NodeProperty => v !== null), + }; + } + const { description: _d, ...rest } = opt; + return rest; + }); + } + + return slim as unknown as NodeProperty; +} + +export function simplifyNodeForLLM(node: NodeDefinition): NodeDefinition { + const cleaned = node.properties + .map(simplifyProperty) + .filter((p): p is NodeProperty => p !== null); + + const seen = new Set(); + const deduped: NodeProperty[] = []; + for (const prop of cleaned) { + if (seen.has(prop.name)) continue; + seen.add(prop.name); + deduped.push(prop); + } + + return { ...node, properties: deduped }; +} diff --git a/src/utils/generation.ts b/src/utils/generation.ts index c4fd3ff..0db5a05 100644 --- a/src/utils/generation.ts +++ b/src/utils/generation.ts @@ -18,6 +18,8 @@ import { FEASIBILITY_CHECK_PROMPT, FIELD_CORRECTION_SYSTEM_PROMPT, FIELD_CORRECTION_USER_PROMPT, + PARAM_CORRECTION_SYSTEM_PROMPT, + PARAM_CORRECTION_USER_PROMPT, } from '../prompts/index'; import { WORKFLOW_MATCHING_SYSTEM_PROMPT } from '../prompts/workflowMatching'; import { @@ -26,7 +28,15 @@ import { draftIntentSchema, feasibilitySchema, } from '../schemas/index'; -import { getNodeDefinition } from './catalog'; +import { getNodeDefinition, simplifyNodeForLLM } from './catalog'; +import { + hasOutputSchema, + getAvailableResources, + getAvailableOperations, + loadOutputSchema, + formatSchemaForPrompt, +} from './outputSchema'; +import type { UnknownParamDetection } from './workflow'; export async function extractKeywords( runtime: IAgentRuntime, @@ -217,18 +227,51 @@ function parseWorkflowResponse(response: string): N8nWorkflow { return workflow; } +/** + * Build output schema context for relevant nodes so the LLM knows the exact + * output fields when writing expressions like {{ $json.field }}. + */ +function buildOutputSchemaContext(nodes: NodeDefinition[]): string { + const sections: string[] = []; + + for (const node of nodes) { + if (!hasOutputSchema(node.name)) continue; + + const resources = getAvailableResources(node.name); + for (const resource of resources) { + const operations = getAvailableOperations(node.name, resource); + for (const operation of operations) { + const result = loadOutputSchema(node.name, resource, operation); + if (!result) continue; + const formatted = formatSchemaForPrompt(result.schema); + sections.push( + `### ${node.name} (resource: "${resource}", operation: "${operation}")\n${formatted}` + ); + } + } + } + + if (sections.length === 0) return ''; + + return `\n## Node Output Schemas\n\nWhen referencing output data from a previous node using expressions like \`{{ $json.field }}\`, use ONLY the field paths listed below. Do NOT invent field names from your training data.\n\n${sections.join('\n\n')}`; +} + export async function generateWorkflow( runtime: IAgentRuntime, userPrompt: string, relevantNodes: NodeDefinition[] ): Promise { + const simplifiedNodes = relevantNodes.map(simplifyNodeForLLM); + const outputSchemaCtx = buildOutputSchemaContext(relevantNodes); + const fullPrompt = `${WORKFLOW_GENERATION_SYSTEM_PROMPT} ## Relevant Nodes Available -${JSON.stringify(relevantNodes, null, 2)} +${JSON.stringify(simplifiedNodes, null, 2)} Use these node definitions to generate the workflow. Each node's "properties" field defines the available parameters. +${outputSchemaCtx} ## User Request @@ -259,13 +302,17 @@ export async function modifyWorkflow( ): Promise { const { _meta, ...workflowForLLM } = existingWorkflow; + const simplifiedNodes = relevantNodes.map(simplifyNodeForLLM); + const outputSchemaCtx = buildOutputSchemaContext(relevantNodes); + const fullPrompt = `${WORKFLOW_GENERATION_SYSTEM_PROMPT} ## Relevant Nodes Available -${JSON.stringify(relevantNodes, null, 2)} +${JSON.stringify(simplifiedNodes, null, 2)} Use these node definitions to modify the workflow. Each node's "properties" field defines the available parameters. +${outputSchemaCtx} ## Existing Workflow (modify this) @@ -478,3 +525,153 @@ function replaceInObject( } } } + +/** + * Try to deterministically rename an unknown key to a valid property name. + * Returns the matching property name or null if no confident match. + */ +function fuzzyMatchParam( + unknownKey: string, + validProps: { name: string; type: string }[] +): string | null { + const lower = unknownKey.toLowerCase(); + + // Substring match: one valid prop contains the unknown key or vice-versa + // Guard: shorter string must be ≥ 60% of the longer to avoid false positives (e.g. "url" in "curl") + const substringMatches = validProps.filter((p) => { + const pLower = p.name.toLowerCase(); + if (!(pLower.includes(lower) || lower.includes(pLower))) return false; + const ratio = Math.min(lower.length, pLower.length) / Math.max(lower.length, pLower.length); + return ratio >= 0.6; + }); + if (substringMatches.length === 1) { + return substringMatches[0].name; + } + + return null; +} + +/** Deterministic fast path + LLM fallback for parameter name correction. */ +export async function correctParameterNames( + runtime: IAgentRuntime, + workflow: N8nWorkflow, + detections: UnknownParamDetection[] +): Promise { + if (detections.length === 0) { + return workflow; + } + + const correctedWorkflow = JSON.parse(JSON.stringify(workflow)) as N8nWorkflow; + + // Phase 1: deterministic renames (no LLM cost) + const needsLLM: UnknownParamDetection[] = []; + + for (const detection of detections) { + const node = correctedWorkflow.nodes.find((n) => n.name === detection.nodeName); + if (!node) continue; + + const remainingUnknowns: string[] = []; + + for (const key of detection.unknownKeys) { + const match = fuzzyMatchParam(key, detection.propertyDefs); + if (match) { + logger.debug( + { src: 'plugin:n8n-workflow:generation:paramCorrection' }, + `Node "${detection.nodeName}": ${key} → ${match} (deterministic)` + ); + node.parameters[match] = node.parameters[key]; + delete node.parameters[key]; + } else { + remainingUnknowns.push(key); + } + } + + if (remainingUnknowns.length > 0) { + needsLLM.push({ + ...detection, + unknownKeys: remainingUnknowns, + currentParams: node.parameters, + }); + } + } + + if (needsLLM.length === 0) { + return correctedWorkflow; + } + + // Phase 2: LLM correction for complex cases (restructuring) + logger.debug( + { src: 'plugin:n8n-workflow:generation:paramCorrection' }, + `LLM correction needed for ${needsLLM.length} node(s): ${needsLLM.map((d) => `"${d.nodeName}" (${d.unknownKeys.join(', ')})`).join('; ')}` + ); + + const corrections = await Promise.all( + needsLLM.map(async (detection) => { + try { + const userPrompt = PARAM_CORRECTION_USER_PROMPT.replace('{nodeType}', detection.nodeType) + .replace('{currentParams}', JSON.stringify(detection.currentParams, null, 2)) + .replace('{propertyDefs}', JSON.stringify(detection.propertyDefs, null, 2)); + + const response = await runtime.useModel(ModelType.TEXT_SMALL, { + prompt: `${PARAM_CORRECTION_SYSTEM_PROMPT}\n\n${userPrompt}`, + temperature: 0, + }); + + const cleaned = (response as string) + .replace(/^[\s\S]*?```(?:json)?\s*\n?/i, '') + .replace(/\n?```[\s\S]*$/i, '') + .trim(); + + const correctedParams = JSON.parse(cleaned) as Record; + + // Validate: corrected params should only contain valid property names + const validNames = new Set(detection.propertyDefs.map((p) => p.name)); + validNames.add('resource'); + validNames.add('operation'); + const invalidKeys = Object.keys(correctedParams).filter((k) => !validNames.has(k)); + if (invalidKeys.length > 0) { + logger.warn( + { src: 'plugin:n8n-workflow:generation:paramCorrection' }, + `LLM returned invalid keys for "${detection.nodeName}": ${invalidKeys.join(', ')} — dropping them` + ); + for (const k of invalidKeys) { + delete correctedParams[k]; + } + } + + return { nodeName: detection.nodeName, correctedParams }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + logger.warn( + { src: 'plugin:n8n-workflow:generation:paramCorrection', error: errMsg }, + `Failed to correct parameters for node "${detection.nodeName}": ${errMsg}` + ); + return null; + } + }) + ); + + for (const correction of corrections) { + if (!correction) continue; + + const node = correctedWorkflow.nodes.find((n) => n.name === correction.nodeName); + if (!node) continue; + + // Preserve resource/operation (already corrected by correctOptionParameters) + if (node.parameters.resource !== undefined) { + correction.correctedParams.resource = node.parameters.resource; + } + if (node.parameters.operation !== undefined) { + correction.correctedParams.operation = node.parameters.operation; + } + + logger.debug( + { src: 'plugin:n8n-workflow:generation:paramCorrection' }, + `Node "${correction.nodeName}": params corrected via LLM — keys: ${Object.keys(correction.correctedParams).join(', ')}` + ); + + node.parameters = correction.correctedParams; + } + + return correctedWorkflow; +} diff --git a/src/utils/outputSchema.ts b/src/utils/outputSchema.ts index 58a0442..a44ad50 100644 --- a/src/utils/outputSchema.ts +++ b/src/utils/outputSchema.ts @@ -199,6 +199,7 @@ function extractExpressionsFromString(str: string, paramPath: string): Expressio field, path: parseFieldPath(field), paramPath, + sourceNodeName: match[1], }); } diff --git a/src/utils/workflow.ts b/src/utils/workflow.ts index b5dcfc1..65eea4a 100644 --- a/src/utils/workflow.ts +++ b/src/utils/workflow.ts @@ -1,10 +1,12 @@ +import { logger } from '@elizaos/core'; import type { N8nWorkflow, NodeProperty, WorkflowValidationResult, OutputRefValidation, + SchemaContent, } from '../types/index'; -import { getNodeDefinition } from './catalog'; +import { getNodeDefinition, simplifyNodeForLLM } from './catalog'; import { loadOutputSchema, loadTriggerOutputSchema, @@ -249,6 +251,9 @@ export function validateNodeInputs(workflow: N8nWorkflow): string[] { continue; } + // Dynamic inputs (n8n expression string) can't be validated statically + if (!Array.isArray(nodeDef.inputs)) continue; + const expectedInputs = nodeDef.inputs.filter((i) => i === 'main').length; const actualInputs = incomingCount.get(node.name) || 0; @@ -315,6 +320,34 @@ export function validateOutputReferences(workflow: N8nWorkflow): OutputRefValida const upstreamMap = buildUpstreamMap(workflow); const nodeMap = new Map(workflow.nodes.map((n) => [n.name, n])); + const schemaCache = new Map< + string, + { schema: SchemaContent; fields: string[]; node: N8nWorkflow['nodes'][0] } | null + >(); + + function getSourceSchema(sourceName: string) { + if (schemaCache.has(sourceName)) { + return schemaCache.get(sourceName)!; + } + const sourceNode = nodeMap.get(sourceName); + if (!sourceNode) { + schemaCache.set(sourceName, null); + return null; + } + const resource = (sourceNode.parameters?.resource as string) || ''; + const operation = (sourceNode.parameters?.operation as string) || ''; + const schemaResult = isTriggerNode(sourceNode.type) + ? loadTriggerOutputSchema(sourceNode.type, sourceNode.parameters as Record) + : loadOutputSchema(sourceNode.type, resource, operation); + if (!schemaResult) { + schemaCache.set(sourceName, null); + return null; + } + const entry = { schema: schemaResult.schema, fields: schemaResult.fields, node: sourceNode }; + schemaCache.set(sourceName, entry); + return entry; + } + for (const node of workflow.nodes) { if (!node.parameters) { continue; @@ -330,36 +363,28 @@ export function validateOutputReferences(workflow: N8nWorkflow): OutputRefValida continue; } - // TODO: Handle $('NodeName').item.json refs to specific nodes - const sourceNodeName = upstreamNames[0]; - const sourceNode = nodeMap.get(sourceNodeName); - if (!sourceNode) { - continue; - } - - const resource = (sourceNode.parameters?.resource as string) || ''; - const operation = (sourceNode.parameters?.operation as string) || ''; - const schemaResult = isTriggerNode(sourceNode.type) - ? loadTriggerOutputSchema(sourceNode.type, sourceNode.parameters as Record) - : loadOutputSchema(sourceNode.type, resource, operation); - if (!schemaResult) { - continue; - } + const defaultSourceName = upstreamNames[0]; for (const expr of expressions) { - const exists = fieldExistsInSchema(expr.path, schemaResult.schema); + const sourceName = expr.sourceNodeName || defaultSourceName; + const cached = getSourceSchema(sourceName); + if (!cached) { + continue; + } + + const exists = fieldExistsInSchema(expr.path, cached.schema); if (!exists) { + const resource = (cached.node.parameters?.resource as string) || ''; + const operation = (cached.node.parameters?.operation as string) || ''; invalidRefs.push({ nodeName: node.name, expression: expr.fullExpression, field: expr.field, - sourceNodeName, - sourceNodeType: sourceNode.type, + sourceNodeName: sourceName, + sourceNodeType: cached.node.type, resource, operation, - availableFields: getAllFieldPathsTyped(schemaResult.schema).map( - (f) => `${f.path} (${f.type})` - ), + availableFields: getAllFieldPathsTyped(cached.schema).map((f) => `${f.path} (${f.type})`), }); } } @@ -368,6 +393,81 @@ export function validateOutputReferences(workflow: N8nWorkflow): OutputRefValida return invalidRefs; } +/** + * Correct invalid option parameter values and typeVersion against catalog definitions. + * Top-level options (resource) are fixed first so displayOptions cascading works for dependent ones (operation). + */ +export function correctOptionParameters(workflow: N8nWorkflow): number { + let corrections = 0; + + for (const node of workflow.nodes) { + const nodeDef = getNodeDefinition(node.type); + if (!nodeDef) continue; + + if (node.type !== nodeDef.name) { + logger.warn( + { src: 'plugin:n8n-workflow:correctOptions' }, + `Node "${node.name}": type "${node.type}" → "${nodeDef.name}"` + ); + node.type = nodeDef.name; + corrections++; + } + + const validVersions = Array.isArray(nodeDef.version) ? nodeDef.version : [nodeDef.version]; + if (node.typeVersion && !validVersions.includes(node.typeVersion)) { + const maxVersion = Math.max(...validVersions); + logger.warn( + { src: 'plugin:n8n-workflow:correctOptions' }, + `Node "${node.name}": typeVersion ${node.typeVersion} → ${maxVersion}` + ); + node.typeVersion = maxVersion; + corrections++; + } + + const topLevel: NodeProperty[] = []; + const dependent: NodeProperty[] = []; + for (const prop of nodeDef.properties) { + if (prop.type !== 'options' || !prop.options?.length) continue; + if (prop.displayOptions) { + dependent.push(prop); + } else { + topLevel.push(prop); + } + } + + for (const prop of topLevel) { + corrections += fixOptionValue(node, prop); + } + + for (const prop of dependent) { + if (!isPropertyVisible(prop, node.parameters)) continue; + corrections += fixOptionValue(node, prop); + } + } + + return corrections; +} + +function fixOptionValue(node: N8nWorkflow['nodes'][0], prop: NodeProperty): number { + const currentValue = node.parameters[prop.name]; + if (currentValue === undefined) return 0; + + const allowedValues = prop.options!.map((o) => o.value); + if (allowedValues.includes(currentValue as string | number | boolean)) return 0; + + const corrected = + prop.default !== undefined && allowedValues.includes(prop.default as string | number | boolean) + ? prop.default + : allowedValues[0]; + + logger.warn( + { src: 'plugin:n8n-workflow:correctOptions' }, + `Node "${node.name}": ${prop.name} "${currentValue}" → "${corrected}"` + ); + node.parameters[prop.name] = corrected; + return 1; +} + function buildUpstreamMap(workflow: N8nWorkflow): Map { const upstream = new Map(); @@ -507,3 +607,94 @@ function positionByLevels( return positioned; } + +/** + * Detect parameters not matching any VISIBLE catalog property. + * e.g. `model` is only valid for `resource: "image"`, not `resource: "text"` (where `modelId` is correct). + * Runs AFTER correctOptionParameters so resource/operation are already valid. + */ +export interface UnknownParamDetection { + nodeName: string; + nodeType: string; + currentParams: Record; + unknownKeys: string[]; + /** Simplified property definitions for this node (used by the LLM to fix params). */ + propertyDefs: NodeProperty[]; +} + +export function detectUnknownParameters(workflow: N8nWorkflow): UnknownParamDetection[] { + const detections: UnknownParamDetection[] = []; + + for (const node of workflow.nodes) { + const nodeDef = getNodeDefinition(node.type); + if (!nodeDef || !node.parameters) continue; + + // Compute visible property names using full definition (with displayOptions) + const visibleNames = new Set(); + for (const prop of nodeDef.properties) { + if (isPropertyVisible(prop, node.parameters)) { + visibleNames.add(prop.name); + } + } + + const unknownKeys: string[] = []; + for (const key of Object.keys(node.parameters)) { + if (!visibleNames.has(key)) { + unknownKeys.push(key); + } + } + + if (unknownKeys.length === 0) continue; + + // Provide simplified visible properties for the LLM correction prompt + const simplified = simplifyNodeForLLM(nodeDef); + const visibleSimplified = simplified.properties.filter((p) => visibleNames.has(p.name)); + + detections.push({ + nodeName: node.name, + nodeType: node.type, + currentParams: node.parameters, + unknownKeys, + propertyDefs: visibleSimplified, + }); + } + + return detections; +} + +/** + * Prefix all string parameter values containing {{ }} with = so n8n evaluates them as expressions. + * Without =, n8n treats {{ }} as literal text. + * Returns the number of values prefixed. + */ +export function ensureExpressionPrefix(workflow: N8nWorkflow): number { + let count = 0; + for (const node of workflow.nodes) { + if (!node.parameters) continue; + count += prefixExpressions(node.parameters); + } + return count; +} + +function prefixExpressions(obj: Record): number { + let count = 0; + for (const key of Object.keys(obj)) { + const value = obj[key]; + if (typeof value === 'string' && value.includes('{{') && !value.startsWith('=')) { + obj[key] = `=${value}`; + count++; + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (typeof value[i] === 'string' && value[i].includes('{{') && !value[i].startsWith('=')) { + value[i] = `=${value[i]}`; + count++; + } else if (typeof value[i] === 'object' && value[i] !== null) { + count += prefixExpressions(value[i] as Record); + } + } + } else if (typeof value === 'object' && value !== null) { + count += prefixExpressions(value as Record); + } + } + return count; +} From e094e0d2917abc0710192d891db33d38425af7d1 Mon Sep 17 00:00:00 2001 From: standujar Date: Tue, 10 Feb 2026 17:09:45 +0100 Subject: [PATCH 5/9] fix: ci --- .github/workflows/ci.yml | 20 +- .github/workflows/npm-deploy.yml | 3 - .github/workflows/schema-update.yml | 3 - scripts/crawl.ts | 15 +- scripts/create-credentials.ts | 280 ---------------------------- scripts/trigger-oauth2-status.md | 65 ------- 6 files changed, 28 insertions(+), 358 deletions(-) delete mode 100644 scripts/create-credentials.ts delete mode 100644 scripts/trigger-oauth2-status.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1732bf..fe2ecb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,10 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - - run: bun run crawl:nodes + - run: bun run crawl + env: + N8N_API_KEY: ${{ secrets.N8N_API_KEY }} + N8N_HOST: ${{ secrets.N8N_HOST }} - run: bun run test:unit test-integration: @@ -32,7 +35,10 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - - run: bun run crawl:nodes + - run: bun run crawl + env: + N8N_API_KEY: ${{ secrets.N8N_API_KEY }} + N8N_HOST: ${{ secrets.N8N_HOST }} - run: bun run test:integration test-e2e: @@ -42,7 +48,10 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - - run: bun run crawl:nodes + - run: bun run crawl + env: + N8N_API_KEY: ${{ secrets.N8N_API_KEY }} + N8N_HOST: ${{ secrets.N8N_HOST }} - run: bun run test:e2e build: @@ -53,5 +62,8 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - - run: bun run crawl:nodes + - run: bun run crawl + env: + N8N_API_KEY: ${{ secrets.N8N_API_KEY }} + N8N_HOST: ${{ secrets.N8N_HOST }} - run: bun run build diff --git a/.github/workflows/npm-deploy.yml b/.github/workflows/npm-deploy.yml index 04c49f2..9f8c0b9 100644 --- a/.github/workflows/npm-deploy.yml +++ b/.github/workflows/npm-deploy.yml @@ -72,9 +72,6 @@ jobs: - name: Generate node catalog and schemas run: bun run crawl - - - name: Capture trigger schemas - run: bun run scripts/capture-trigger-schemas.ts --from-existing env: N8N_API_KEY: ${{ secrets.N8N_API_KEY }} N8N_HOST: ${{ secrets.N8N_HOST }} diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml index 6f5e661..31076b6 100644 --- a/.github/workflows/schema-update.yml +++ b/.github/workflows/schema-update.yml @@ -18,9 +18,6 @@ jobs: - name: Generate schemas run: bun run crawl - - - name: Capture trigger schemas - run: bun run scripts/capture-trigger-schemas.ts --from-existing env: N8N_API_KEY: ${{ secrets.N8N_API_KEY }} N8N_HOST: ${{ secrets.N8N_HOST }} diff --git a/scripts/crawl.ts b/scripts/crawl.ts index 4bf2684..f091dde 100644 --- a/scripts/crawl.ts +++ b/scripts/crawl.ts @@ -6,18 +6,27 @@ * Generates: * - src/data/defaultNodes.json (node catalog) * - src/data/schemaIndex.json (output schema index) + * - src/data/triggerSchemaIndex.json (trigger output schemas, requires N8N_HOST + N8N_API_KEY) */ +import { $ } from 'bun'; + async function main() { console.log('=== Crawling n8n-nodes-base ===\n'); - // Run crawl-nodes - console.log('1/2: Crawling node definitions...'); + console.log('1/3: Crawling node definitions...'); await import('./crawl-nodes'); - console.log('\n2/2: Crawling output schemas...'); + console.log('\n2/3: Crawling output schemas...'); await import('./crawl-schemas'); + console.log('\n3/3: Capturing trigger schemas...'); + if (process.env.N8N_HOST && process.env.N8N_API_KEY) { + await $`bun run scripts/capture-trigger-schemas.ts --from-existing`; + } else { + console.log(' Skipped (N8N_HOST / N8N_API_KEY not set)'); + } + console.log('\n=== Done ==='); } diff --git a/scripts/create-credentials.ts b/scripts/create-credentials.ts deleted file mode 100644 index 984a711..0000000 --- a/scripts/create-credentials.ts +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env npx ts-node -/** - * Create n8n credentials for trigger schema capture. - * - * Supports two modes: - * 1. API key / token credentials → created and immediately usable - * 2. OAuth2 credentials → created with clientId/clientSecret, user must complete flow in n8n UI - * - * Usage: - * N8N_HOST=http://localhost:5678 N8N_API_KEY=xxx bun run scripts/create-credentials.ts - * - * Required environment variables per service: - * - * LINEAR (API key): LINEAR_API_KEY - * LINEAR (OAuth2): LINEAR_CLIENT_ID, LINEAR_CLIENT_SECRET - * - * SLACK: SLACK_ACCESS_TOKEN (xoxb-... bot token) - * SLACK_SIGNING_SECRET (optional) - * - * GITHUB (token): GITHUB_USER, GITHUB_ACCESS_TOKEN - * GITHUB (OAuth2): GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET - * - * NOTION: NOTION_API_KEY (internal integration secret) - * - * TWITTER (OAuth 1.0a): TWITTER_API_KEY, TWITTER_API_SECRET, - * TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET - * - * Set --list to show existing credentials instead of creating. - * Set --delete-all to remove all credentials with "[Auto]" prefix. - */ - -const N8N_HOST = process.env.N8N_HOST; -const N8N_API_KEY = process.env.N8N_API_KEY; - -if (!N8N_HOST || !N8N_API_KEY) { - console.error('Missing N8N_HOST or N8N_API_KEY environment variables'); - process.exit(1); -} - -// ─── n8n API helpers ───────────────────────────────────────────────────────── - -async function n8nRequest(method: string, endpoint: string, body?: unknown): Promise { - const url = `${N8N_HOST}/api/v1${endpoint}`; - const options: RequestInit = { - method, - headers: { - 'Content-Type': 'application/json', - 'X-N8N-API-KEY': N8N_API_KEY!, - }, - }; - if (body) options.body = JSON.stringify(body); - - const response = await fetch(url, options); - if (response.status === 204) return undefined as T; - if (!response.ok) { - const text = await response.text(); - throw new Error(`n8n API ${method} ${endpoint}: ${response.status} ${text}`); - } - const text = await response.text(); - return text ? JSON.parse(text) : undefined; -} - -interface N8nCredential { - id: string; - name: string; - type: string; -} - -async function listCredentials(): Promise { - const result = await n8nRequest<{ data: N8nCredential[] }>('GET', '/credentials'); - return result.data; -} - -async function createCredential( - name: string, - type: string, - data: Record -): Promise { - return n8nRequest('POST', '/credentials', { name, type, data }); -} - -async function deleteCredential(id: string): Promise { - await n8nRequest('DELETE', `/credentials/${id}`); -} - -// ─── Credential definitions ────────────────────────────────────────────────── - -interface CredentialConfig { - name: string; - type: string; - data: Record; - oauth2Flow?: boolean; // true = user must complete OAuth in n8n UI -} - -function getCredentialConfigs(): CredentialConfig[] { - const configs: CredentialConfig[] = []; - - // ── Linear ── - if (process.env.LINEAR_API_KEY) { - configs.push({ - name: '[Auto] Linear API Key', - type: 'linearApi', - data: { apiKey: process.env.LINEAR_API_KEY }, - }); - } - if (process.env.LINEAR_CLIENT_ID && process.env.LINEAR_CLIENT_SECRET) { - configs.push({ - name: '[Auto] Linear OAuth2', - type: 'linearOAuth2Api', - data: { - clientId: process.env.LINEAR_CLIENT_ID, - clientSecret: process.env.LINEAR_CLIENT_SECRET, - actor: 'user', - includeAdminScope: true, // needed for webhooks - }, - oauth2Flow: true, - }); - } - - // ── Slack ── - if (process.env.SLACK_ACCESS_TOKEN) { - configs.push({ - name: '[Auto] Slack Bot Token', - type: 'slackApi', - data: { - accessToken: process.env.SLACK_ACCESS_TOKEN, - ...(process.env.SLACK_SIGNING_SECRET && { - signatureSecret: process.env.SLACK_SIGNING_SECRET, - }), - }, - }); - } - - // ── GitHub ── - if (process.env.GITHUB_ACCESS_TOKEN) { - configs.push({ - name: '[Auto] GitHub Token', - type: 'githubApi', - data: { - user: process.env.GITHUB_USER || '', - accessToken: process.env.GITHUB_ACCESS_TOKEN, - server: 'https://api.github.com', - }, - }); - } - if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { - configs.push({ - name: '[Auto] GitHub OAuth2', - type: 'githubOAuth2Api', - data: { - clientId: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - server: 'https://api.github.com', - }, - oauth2Flow: true, - }); - } - - // ── Notion ── - if (process.env.NOTION_API_KEY) { - configs.push({ - name: '[Auto] Notion Integration', - type: 'notionApi', - data: { apiKey: process.env.NOTION_API_KEY }, - }); - } - - // ── Twitter/X OAuth 1.0a ── - if (process.env.TWITTER_API_KEY && process.env.TWITTER_ACCESS_TOKEN) { - configs.push({ - name: '[Auto] Twitter OAuth1', - type: 'twitterOAuth1Api', - data: { - consumerKey: process.env.TWITTER_API_KEY, - consumerSecret: process.env.TWITTER_API_SECRET || '', - accessToken: process.env.TWITTER_ACCESS_TOKEN, - accessTokenSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET || '', - }, - }); - } - - return configs; -} - -// ─── Main ──────────────────────────────────────────────────────────────────── - -async function main() { - const args = process.argv.slice(2); - - // --list mode - if (args.includes('--list')) { - const creds = await listCredentials(); - console.log(`Found ${creds.length} credentials:\n`); - for (const cred of creds) { - console.log(` [${cred.id}] ${cred.type} — "${cred.name}"`); - } - return; - } - - // --delete-all mode (only [Auto] prefixed ones) - if (args.includes('--delete-all')) { - const creds = await listCredentials(); - const autoCreated = creds.filter((c) => c.name.startsWith('[Auto]')); - console.log(`Deleting ${autoCreated.length} auto-created credentials...`); - for (const cred of autoCreated) { - await deleteCredential(cred.id); - console.log(` Deleted: ${cred.type} — "${cred.name}"`); - } - return; - } - - // Create credentials - const configs = getCredentialConfigs(); - - if (configs.length === 0) { - console.error('No credential environment variables found.\n'); - console.error('Set at least one of:'); - console.error(' LINEAR_API_KEY — Linear personal API key'); - console.error(' SLACK_ACCESS_TOKEN — Slack bot token (xoxb-...)'); - console.error(' GITHUB_ACCESS_TOKEN — GitHub personal access token'); - console.error(' NOTION_API_KEY — Notion internal integration secret'); - console.error(' TWITTER_API_KEY + TWITTER_ACCESS_TOKEN — Twitter OAuth 1.0a'); - console.error('\nFor OAuth2 flow (requires n8n UI to complete):'); - console.error(' LINEAR_CLIENT_ID + LINEAR_CLIENT_SECRET'); - console.error(' GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET'); - process.exit(1); - } - - // Check for existing credentials to avoid duplicates - const existing = await listCredentials(); - const existingTypes = new Set(existing.map((c) => c.type)); - - console.log(`Creating ${configs.length} credentials in n8n...\n`); - - const needsOAuth: string[] = []; - - for (const config of configs) { - if (existingTypes.has(config.type)) { - const existingCred = existing.find((c) => c.type === config.type)!; - console.log( - ` SKIP ${config.type} — already exists: "${existingCred.name}" (${existingCred.id})` - ); - continue; - } - - try { - const created = await createCredential(config.name, config.type, config.data); - console.log(` OK ${config.type} — "${config.name}" (${created.id})`); - - if (config.oauth2Flow) { - needsOAuth.push(`${config.type} → ${N8N_HOST}/credentials/${created.id}`); - } - } catch (error) { - console.log(` FAIL ${config.type} — ${error instanceof Error ? error.message : error}`); - } - } - - if (needsOAuth.length > 0) { - console.log('\n── OAuth2 credentials need manual authorization ──'); - console.log('Open these URLs in your browser to complete the OAuth flow:\n'); - for (const url of needsOAuth) { - console.log(` ${url}`); - } - } - - console.log('\n── Current credentials ──'); - const allCreds = await listCredentials(); - for (const cred of allCreds) { - console.log(` [${cred.id}] ${cred.type} — "${cred.name}"`); - } - - console.log(`\nTotal: ${allCreds.length} credentials`); - console.log('\nRun the capture script next:'); - console.log(` N8N_HOST=${N8N_HOST} N8N_API_KEY=*** bun run scripts/capture-trigger-schemas.ts`); -} - -main().catch((error) => { - console.error('Fatal error:', error); - process.exit(1); -}); diff --git a/scripts/trigger-oauth2-status.md b/scripts/trigger-oauth2-status.md deleted file mode 100644 index 1d634b3..0000000 --- a/scripts/trigger-oauth2-status.md +++ /dev/null @@ -1,65 +0,0 @@ -# Trigger OAuth2 Status - -31 n8n triggers support OAuth2 credentials. This file tracks which ones have credentials created and tested. - -## Credentials Created (7/31) - -| # | Trigger | OAuth2 Credential Type | n8n Cred ID | Status | -|---|---------|----------------------|-------------|--------| -| 1 | GmailTrigger | `gmailOAuth2` | `hjSOdmEwxXzQpj3V` | created | -| 2 | GoogleCalendarTrigger | `googleCalendarOAuth2Api` | `yHjZgYhYPxTOFwdr` | created | -| 3 | GoogleDriveTrigger | `googleDriveOAuth2Api` | `LoKYrCVJOpU8PhAM` | created | -| 4 | GoogleSheetsTrigger | `googleSheetsTriggerOAuth2Api` | `UcHkFKN0Khm7z1O3` | created | -| 5 | GoogleBusinessProfileTrigger | `googleBusinessProfileOAuth2Api` | `lo6OS1caDfS84dgR` | created | -| 6 | GithubTrigger | `githubOAuth2Api` | `T3JHHfCQmwbQgtEr` | created | -| 7 | LinearTrigger | `linearOAuth2Api` | `jADHC6LxJ8ax7JMc` | created | - -## Not Created Yet (24/31) - -Need OAuth app credentials (clientId + clientSecret) for these services: - -| # | Trigger | OAuth2 Credential Type | Notes | -|---|---------|----------------------|-------| -| 7 | AcuitySchedulingTrigger | `acuitySchedulingOAuth2Api` | | -| 8 | AirtableTrigger | `airtableOAuth2Api` | | -| 9 | AsanaTrigger | `asanaOAuth2Api` | | -| 10 | BoxTrigger | `boxOAuth2Api` | | -| 11 | CalendlyTrigger | `calendlyOAuth2Api` | | -| 12 | CiscoWebexTrigger | `ciscoWebexOAuth2Api` | | -| 13 | ClickUpTrigger | `clickUpOAuth2Api` | | -| 14 | EventbriteTrigger | `eventbriteOAuth2Api` | | -| 15 | FacebookLeadAdsTrigger | `facebookLeadAdsOAuth2Api` | | -| 16 | FormstackTrigger | `formstackOAuth2Api` | | -| 17 | GetResponseTrigger | `getResponseOAuth2Api` | | -| 18 | HelpScoutTrigger | `helpScoutOAuth2Api` | | -| 19 | KeapTrigger | `keapOAuth2Api` | | -| 20 | MailchimpTrigger | `mailchimpOAuth2Api` | | -| 21 | MauticTrigger | `mauticOAuth2Api` | | -| 22 | MicrosoftOneDriveTrigger | `microsoftOneDriveOAuth2Api` | | -| 23 | MicrosoftOutlookTrigger | `microsoftOutlookOAuth2Api` | | -| 24 | MicrosoftTeamsTrigger | `microsoftTeamsOAuth2Api` | | -| 25 | PipedriveTrigger | `pipedriveOAuth2Api` | | -| 26 | SalesforceTrigger | `salesforceOAuth2Api` | | -| 27 | ShopifyTrigger | `shopifyOAuth2Api` | | -| 28 | StravaTrigger | `stravaOAuth2Api` | | -| 29 | SurveyMonkeyTrigger | `surveyMonkeyOAuth2Api` | | -| 30 | TypeformTrigger | `typeformOAuth2Api` | | -| 31 | ZendeskTrigger | `zendeskOAuth2Api` | | - -## Not Supported (triggers without OAuth2) - -These triggers only accept API key/token credentials. The bridge must convert OAuth2 tokens to the right format. - -| Trigger | Required Credential | Data Format | -|---------|-------------------|-------------| -| SlackTrigger | `slackApi` | `{ accessToken: "xoxb-..." }` | -| NotionTrigger | `notionApi` | `{ apiKey: "secret_..." }` | -| StripeTrigger | `stripeApi` | `{ apiKey: "sk_..." }` | -| TelegramTrigger | `telegramApi` | `{ accessToken: "bot..." }` | -| + 49 others | various `*Api` | API key/token | - -## Next Steps - -1. Complete OAuth flow in n8n UI for the 6 created credentials -2. Run `capture-trigger-schemas.ts` to capture real output schemas -3. Create OAuth apps for remaining 25 services as needed \ No newline at end of file From a0df5e9ed48fb0f053e70c68b188cd4e0194c727 Mon Sep 17 00:00:00 2001 From: standujar Date: Tue, 10 Feb 2026 17:15:11 +0100 Subject: [PATCH 6/9] fix: readme & lint --- README.md | 114 ++++++++++++++++++++++++++++++++++++---- scripts/crawl.ts | 4 +- src/utils/catalog.ts | 4 +- src/utils/generation.ts | 28 +++++++--- src/utils/workflow.ts | 36 +++++++++---- 5 files changed, 158 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d27f70e..26bf37e 100644 --- a/README.md +++ b/README.md @@ -471,6 +471,7 @@ User Prompt: "Send me Stripe payment summaries every Monday via Gmail" ┌──────────────────────────────────────────┐ │ 3. generateWorkflow (TEXT_LARGE) │ │ Input: user prompt + node defs │ +│ + output schemas (from index) │ │ Config: temperature 0, JSON mode │ │ Output: complete n8n workflow JSON │ │ Includes: _meta.assumptions, │ @@ -479,7 +480,36 @@ User Prompt: "Send me Stripe payment summaries every Monday via Gmail" └──────────────────┬───────────────────────┘ ▼ ┌──────────────────────────────────────────┐ -│ 4. validateWorkflow │ +│ 4. normalizeTriggerSimpleParam │ +│ Set simple=true on trigger nodes │ +└──────────────────┬───────────────────────┘ + ▼ +┌──────────────────────────────────────────┐ +│ 5. correctOptionParameters │ +│ Fix node types, versions, resources, │ +│ and operations against the catalog │ +└──────────────────┬───────────────────────┘ + ▼ +┌──────────────────────────────────────────┐ +│ 6. detectUnknownParameters │ +│ + correctParameterNames (LLM) │ +│ Fix param names not in catalog │ +└──────────────────┬───────────────────────┘ + ▼ +┌──────────────────────────────────────────┐ +│ 7. validateOutputReferences │ +│ + correctFieldReferences (LLM) │ +│ Validate $json expressions against │ +│ output schemas, fix invalid paths │ +└──────────────────┬───────────────────────┘ + ▼ +┌──────────────────────────────────────────┐ +│ 8. ensureExpressionPrefix │ +│ Wrap {{ }} with ={{ }} for n8n │ +└───────��──────────┬───────────────────────┘ + ▼ +┌──────────────────────────────────────────┐ +│ 9. validateWorkflow │ │ - nodes array exists, non-empty │ │ - connections object valid │ │ - required fields on each node │ @@ -492,7 +522,7 @@ User Prompt: "Send me Stripe payment summaries every Monday via Gmail" └──────────────────┬───────────────────────┘ ▼ ┌──────────────────────────────────────────┐ -│ 5. injectCatalogClarifications │ +│ 10. injectCatalogClarifications │ │ Check each node against catalog: │ │ - validateNodeParameters │ │ → missing required params? │ @@ -502,7 +532,7 @@ User Prompt: "Send me Stripe payment summaries every Monday via Gmail" └──────────────────┬───────────────────────┘ ▼ ┌──────────────────────────────────────────┐ -│ 6. positionNodes │ +│ 11. positionNodes │ │ BFS layout from trigger nodes: │ │ - Triggers at x=250 │ │ - Each level: x += 250 │ @@ -847,6 +877,7 @@ src/ │ ├── keywordExtraction.ts # System prompt for keyword extraction │ ├── workflowMatching.ts # System prompt for semantic matching │ ├── draftIntent.ts # System prompt for intent classification +│ ├── parameterCorrection.ts # System prompt for parameter name correction │ └── actionResponse.ts # System prompt for response formatting ├── schemas/ │ ├── keywordExtraction.ts # JSON schema for keyword output @@ -854,6 +885,10 @@ src/ │ └── draftIntent.ts # JSON schema for intent output ├── types/ │ └── index.ts # All TypeScript interfaces and types +├── data/ # Generated data (run `bun run crawl`) +│ ├── defaultNodes.json # 457 n8n node definitions (types, params, versions) +│ ├── schemaIndex.json # Output schemas per node/resource/operation +│ └── triggerSchemaIndex.json # Trigger output schemas (captured from n8n) ├── db/ │ └── schema.ts # Drizzle ORM schema (PostgreSQL) └── utils/ @@ -862,19 +897,80 @@ src/ ├── context.ts # Conversation context builder + user tag naming ├── credentialResolver.ts # 4-step credential resolution chain ├── generation.ts # LLM utilities (extract, generate, match, classify, format) - └── workflow.ts # Validation, positioning, auto-fix + ├── outputSchema.ts # Output schema validation + expression parsing + └── workflow.ts # Validation, positioning, corrections, auto-fix + +scripts/ +├── crawl.ts # Master crawl (runs all 3 steps below) +├── crawl-nodes.ts # Step 1: Extract node defs from n8n-nodes-base +├── crawl-schemas.ts # Step 2: Extract output schemas from __schema__/ dirs +├── capture-trigger-schemas.ts # Step 3: Capture trigger schemas from live n8n +└── langchain-output-schemas.json # Manual overrides for @n8n/n8n-nodes-langchain ``` --- +## Data Generation + +The plugin relies on three generated data files in `src/data/`. These are **not committed to git** — they are regenerated by `bun run crawl`. + +### `bun run crawl` + +Runs three steps: + +| Step | Script | Generates | Requires | +|------|--------|-----------|----------| +| 1/3 | `crawl-nodes.ts` | `defaultNodes.json` — 457 node definitions with parameters, versions, credentials | n8n-nodes-base package | +| 2/3 | `crawl-schemas.ts` | `schemaIndex.json` — output schemas per node/resource/operation | n8n-nodes-base `__schema__/` dirs + langchain overrides | +| 3/3 | `capture-trigger-schemas.ts` | `triggerSchemaIndex.json` — trigger output schemas | `N8N_HOST` + `N8N_API_KEY` env vars | + +**Step 3 is skipped** if `N8N_HOST` / `N8N_API_KEY` are not set (a warning is printed). + +### Output Schema System + +The LLM receives output schemas during workflow generation to prevent hallucinated field paths: + +- **n8n-nodes-base nodes**: Schemas are crawled from `__schema__/` directories in the npm package +- **@n8n/n8n-nodes-langchain nodes**: No `__schema__/` dirs exist — schemas are defined in `scripts/langchain-output-schemas.json` and merged at crawl time +- **Trigger nodes**: Schemas are captured from real n8n executions via `capture-trigger-schemas.ts` + +The generation prompt includes output schemas for all relevant nodes, so the LLM uses correct field paths like `$json.output[0].content[0].text` instead of inventing wrong ones like `$json.choices[0].message.content`. + +As a safety net, `validateOutputReferences` checks all `$json` expressions against schemas post-generation and `correctFieldReferences` (LLM) fixes any remaining invalid paths. + +--- + ## Development +### Prerequisites + +| Requirement | Purpose | +|-------------|---------| +| [Bun](https://bun.sh) | Runtime and package manager | +| `N8N_HOST` | n8n instance URL (for trigger schema capture) | +| `N8N_API_KEY` | n8n API key (for trigger schema capture) | + +### Setup + +```bash +bun install # Install dependencies +N8N_HOST=https://... N8N_API_KEY=... bun run crawl # Generate data files +bun run build # Compile TypeScript +``` + +### Commands + ```bash -bun install # install dependencies -bun run build # compile TypeScript -bun test # run tests (162 tests) -bun run lint # lint -bun run format # format +bun run crawl # Generate all data files (nodes + schemas + triggers) +bun run crawl:nodes # Generate only defaultNodes.json +bun run crawl:schemas # Generate only schemaIndex.json +bun run build # Compile TypeScript +bun test # Run all tests (~260 tests) +bun run test:unit # Run unit tests only +bun run test:integration # Run integration tests only +bun run test:e2e # Run e2e tests only +bun run lint # Lint +bun run format # Format ``` ## License diff --git a/scripts/crawl.ts b/scripts/crawl.ts index f091dde..f783f2e 100644 --- a/scripts/crawl.ts +++ b/scripts/crawl.ts @@ -15,10 +15,10 @@ async function main() { console.log('=== Crawling n8n-nodes-base ===\n'); console.log('1/3: Crawling node definitions...'); - await import('./crawl-nodes'); + await $`bun run scripts/crawl-nodes.ts`; console.log('\n2/3: Crawling output schemas...'); - await import('./crawl-schemas'); + await $`bun run scripts/crawl-schemas.ts`; console.log('\n3/3: Capturing trigger schemas...'); if (process.env.N8N_HOST && process.env.N8N_API_KEY) { diff --git a/src/utils/catalog.ts b/src/utils/catalog.ts index f7a3313..afe9e8c 100644 --- a/src/utils/catalog.ts +++ b/src/utils/catalog.ts @@ -202,7 +202,9 @@ export function simplifyNodeForLLM(node: NodeDefinition): NodeDefinition { const seen = new Set(); const deduped: NodeProperty[] = []; for (const prop of cleaned) { - if (seen.has(prop.name)) continue; + if (seen.has(prop.name)) { + continue; + } seen.add(prop.name); deduped.push(prop); } diff --git a/src/utils/generation.ts b/src/utils/generation.ts index 0db5a05..c0d2639 100644 --- a/src/utils/generation.ts +++ b/src/utils/generation.ts @@ -235,14 +235,18 @@ function buildOutputSchemaContext(nodes: NodeDefinition[]): string { const sections: string[] = []; for (const node of nodes) { - if (!hasOutputSchema(node.name)) continue; + if (!hasOutputSchema(node.name)) { + continue; + } const resources = getAvailableResources(node.name); for (const resource of resources) { const operations = getAvailableOperations(node.name, resource); for (const operation of operations) { const result = loadOutputSchema(node.name, resource, operation); - if (!result) continue; + if (!result) { + continue; + } const formatted = formatSchemaForPrompt(result.schema); sections.push( `### ${node.name} (resource: "${resource}", operation: "${operation}")\n${formatted}` @@ -251,7 +255,9 @@ function buildOutputSchemaContext(nodes: NodeDefinition[]): string { } } - if (sections.length === 0) return ''; + if (sections.length === 0) { + return ''; + } return `\n## Node Output Schemas\n\nWhen referencing output data from a previous node using expressions like \`{{ $json.field }}\`, use ONLY the field paths listed below. Do NOT invent field names from your training data.\n\n${sections.join('\n\n')}`; } @@ -540,7 +546,9 @@ function fuzzyMatchParam( // Guard: shorter string must be ≥ 60% of the longer to avoid false positives (e.g. "url" in "curl") const substringMatches = validProps.filter((p) => { const pLower = p.name.toLowerCase(); - if (!(pLower.includes(lower) || lower.includes(pLower))) return false; + if (!(pLower.includes(lower) || lower.includes(pLower))) { + return false; + } const ratio = Math.min(lower.length, pLower.length) / Math.max(lower.length, pLower.length); return ratio >= 0.6; }); @@ -568,7 +576,9 @@ export async function correctParameterNames( for (const detection of detections) { const node = correctedWorkflow.nodes.find((n) => n.name === detection.nodeName); - if (!node) continue; + if (!node) { + continue; + } const remainingUnknowns: string[] = []; @@ -652,10 +662,14 @@ export async function correctParameterNames( ); for (const correction of corrections) { - if (!correction) continue; + if (!correction) { + continue; + } const node = correctedWorkflow.nodes.find((n) => n.name === correction.nodeName); - if (!node) continue; + if (!node) { + continue; + } // Preserve resource/operation (already corrected by correctOptionParameters) if (node.parameters.resource !== undefined) { diff --git a/src/utils/workflow.ts b/src/utils/workflow.ts index 65eea4a..8391f3b 100644 --- a/src/utils/workflow.ts +++ b/src/utils/workflow.ts @@ -252,7 +252,9 @@ export function validateNodeInputs(workflow: N8nWorkflow): string[] { } // Dynamic inputs (n8n expression string) can't be validated statically - if (!Array.isArray(nodeDef.inputs)) continue; + if (!Array.isArray(nodeDef.inputs)) { + continue; + } const expectedInputs = nodeDef.inputs.filter((i) => i === 'main').length; const actualInputs = incomingCount.get(node.name) || 0; @@ -402,7 +404,9 @@ export function correctOptionParameters(workflow: N8nWorkflow): number { for (const node of workflow.nodes) { const nodeDef = getNodeDefinition(node.type); - if (!nodeDef) continue; + if (!nodeDef) { + continue; + } if (node.type !== nodeDef.name) { logger.warn( @@ -427,7 +431,9 @@ export function correctOptionParameters(workflow: N8nWorkflow): number { const topLevel: NodeProperty[] = []; const dependent: NodeProperty[] = []; for (const prop of nodeDef.properties) { - if (prop.type !== 'options' || !prop.options?.length) continue; + if (prop.type !== 'options' || !prop.options?.length) { + continue; + } if (prop.displayOptions) { dependent.push(prop); } else { @@ -440,7 +446,9 @@ export function correctOptionParameters(workflow: N8nWorkflow): number { } for (const prop of dependent) { - if (!isPropertyVisible(prop, node.parameters)) continue; + if (!isPropertyVisible(prop, node.parameters)) { + continue; + } corrections += fixOptionValue(node, prop); } } @@ -450,10 +458,14 @@ export function correctOptionParameters(workflow: N8nWorkflow): number { function fixOptionValue(node: N8nWorkflow['nodes'][0], prop: NodeProperty): number { const currentValue = node.parameters[prop.name]; - if (currentValue === undefined) return 0; + if (currentValue === undefined) { + return 0; + } const allowedValues = prop.options!.map((o) => o.value); - if (allowedValues.includes(currentValue as string | number | boolean)) return 0; + if (allowedValues.includes(currentValue as string | number | boolean)) { + return 0; + } const corrected = prop.default !== undefined && allowedValues.includes(prop.default as string | number | boolean) @@ -627,7 +639,9 @@ export function detectUnknownParameters(workflow: N8nWorkflow): UnknownParamDete for (const node of workflow.nodes) { const nodeDef = getNodeDefinition(node.type); - if (!nodeDef || !node.parameters) continue; + if (!nodeDef || !node.parameters) { + continue; + } // Compute visible property names using full definition (with displayOptions) const visibleNames = new Set(); @@ -644,7 +658,9 @@ export function detectUnknownParameters(workflow: N8nWorkflow): UnknownParamDete } } - if (unknownKeys.length === 0) continue; + if (unknownKeys.length === 0) { + continue; + } // Provide simplified visible properties for the LLM correction prompt const simplified = simplifyNodeForLLM(nodeDef); @@ -670,7 +686,9 @@ export function detectUnknownParameters(workflow: N8nWorkflow): UnknownParamDete export function ensureExpressionPrefix(workflow: N8nWorkflow): number { let count = 0; for (const node of workflow.nodes) { - if (!node.parameters) continue; + if (!node.parameters) { + continue; + } count += prefixExpressions(node.parameters); } return count; From bd69c8d296102ae0a8432ae583f7da7a0e4e4739 Mon Sep 17 00:00:00 2001 From: standujar Date: Tue, 10 Feb 2026 17:18:10 +0100 Subject: [PATCH 7/9] fix: ci and crawl --- README.md | 10 +++++----- scripts/crawl-schemas.ts | 8 +++++++- {scripts => src/data}/langchain-output-schemas.json | 0 3 files changed, 12 insertions(+), 6 deletions(-) rename {scripts => src/data}/langchain-output-schemas.json (100%) diff --git a/README.md b/README.md index 26bf37e..3e1abd0 100644 --- a/README.md +++ b/README.md @@ -455,7 +455,7 @@ User Prompt: "Send me Stripe payment summaries every Monday via Gmail" │ Max: 5 keywords │ └──────────────────┬───────────────────────┘ ▼ -┌──────────────────────────────────────────┐ +┌────────────────────�����────────────────────┐ │ 2. searchNodes (local catalog) │ │ 457 embedded n8n node definitions │ │ Keyword scoring: │ @@ -888,7 +888,8 @@ src/ ├── data/ # Generated data (run `bun run crawl`) │ ├── defaultNodes.json # 457 n8n node definitions (types, params, versions) │ ├── schemaIndex.json # Output schemas per node/resource/operation -│ └── triggerSchemaIndex.json # Trigger output schemas (captured from n8n) +│ ├── triggerSchemaIndex.json # Trigger output schemas (captured from n8n) +│ └── langchain-output-schemas.json # Manual overrides for langchain nodes ├── db/ │ └── schema.ts # Drizzle ORM schema (PostgreSQL) └── utils/ @@ -904,8 +905,7 @@ scripts/ ├── crawl.ts # Master crawl (runs all 3 steps below) ├── crawl-nodes.ts # Step 1: Extract node defs from n8n-nodes-base ├── crawl-schemas.ts # Step 2: Extract output schemas from __schema__/ dirs -├── capture-trigger-schemas.ts # Step 3: Capture trigger schemas from live n8n -└── langchain-output-schemas.json # Manual overrides for @n8n/n8n-nodes-langchain +└── capture-trigger-schemas.ts # Step 3: Capture trigger schemas from live n8n ``` --- @@ -931,7 +931,7 @@ Runs three steps: The LLM receives output schemas during workflow generation to prevent hallucinated field paths: - **n8n-nodes-base nodes**: Schemas are crawled from `__schema__/` directories in the npm package -- **@n8n/n8n-nodes-langchain nodes**: No `__schema__/` dirs exist — schemas are defined in `scripts/langchain-output-schemas.json` and merged at crawl time +- **@n8n/n8n-nodes-langchain nodes**: No `__schema__/` dirs exist — schemas are defined in `src/data/langchain-output-schemas.json` and merged at crawl time - **Trigger nodes**: Schemas are captured from real n8n executions via `capture-trigger-schemas.ts` The generation prompt includes output schemas for all relevant nodes, so the LLM uses correct field paths like `$json.output[0].content[0].text` instead of inventing wrong ones like `$json.choices[0].message.content`. diff --git a/scripts/crawl-schemas.ts b/scripts/crawl-schemas.ts index 69e1523..6c73522 100644 --- a/scripts/crawl-schemas.ts +++ b/scripts/crawl-schemas.ts @@ -15,7 +15,13 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; const OUTPUT = path.resolve(import.meta.dir, '..', 'src', 'data', 'schemaIndex.json'); -const LANGCHAIN_OVERRIDES = path.resolve(import.meta.dir, 'langchain-output-schemas.json'); +const LANGCHAIN_OVERRIDES = path.resolve( + import.meta.dir, + '..', + 'src', + 'data', + 'langchain-output-schemas.json' +); // Full schema content embedded interface SchemaContent { diff --git a/scripts/langchain-output-schemas.json b/src/data/langchain-output-schemas.json similarity index 100% rename from scripts/langchain-output-schemas.json rename to src/data/langchain-output-schemas.json From 22d87ba7eddc70a873b7102986c6c6f631fdc589 Mon Sep 17 00:00:00 2001 From: standujar Date: Tue, 10 Feb 2026 17:22:13 +0100 Subject: [PATCH 8/9] fix: trigger crawl --- scripts/capture-trigger-schemas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/capture-trigger-schemas.ts b/scripts/capture-trigger-schemas.ts index 97fd6e7..a034f4c 100644 --- a/scripts/capture-trigger-schemas.ts +++ b/scripts/capture-trigger-schemas.ts @@ -171,7 +171,7 @@ function discoverTriggers(): TriggerInfo[] { const credentialTypes = (n.credentials || []).map((c) => c.name); return { - nodeType: `n8n-nodes-base.${n.name}`, + nodeType: n.name, displayName: n.displayName, triggerType, credentialTypes, From c66b2581b6b5a79c208bd976334587518a1ca1b1 Mon Sep 17 00:00:00 2001 From: standujar Date: Tue, 10 Feb 2026 17:26:54 +0100 Subject: [PATCH 9/9] fix: command capture trigger --- scripts/capture-trigger-schemas.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/capture-trigger-schemas.ts b/scripts/capture-trigger-schemas.ts index a034f4c..3edf2ef 100644 --- a/scripts/capture-trigger-schemas.ts +++ b/scripts/capture-trigger-schemas.ts @@ -308,9 +308,12 @@ async function main() { const timeoutSec = parseInt(args.find((a) => a.startsWith('--timeout='))?.split('=')[1] ?? '30'); const keepWorkflows = args.includes('--keep'); const createOnly = args.includes('--create-only'); + const fromExisting = args.includes('--from-existing'); console.log(`n8n: ${N8N_HOST}`); - console.log(`Timeout: ${timeoutSec}s | Keep: ${keepWorkflows} | Create-only: ${createOnly}`); + console.log( + `Timeout: ${timeoutSec}s | Keep: ${keepWorkflows} | Create-only: ${createOnly} | From-existing: ${fromExisting}` + ); if (filterTrigger) console.log(`Filter: ${filterTrigger}`); console.log(); @@ -366,7 +369,21 @@ async function main() { console.log(); continue; } - console.log(` No execution yet — activating...`); + console.log(` No execution yet`); + if (fromExisting) { + console.log(` Skipped (--from-existing)`); + result.stats.skipped++; + console.log(); + continue; + } + console.log(` Activating...`); + } + + if (fromExisting) { + console.log(` No existing workflow — skipped (--from-existing)`); + result.stats.skipped++; + console.log(); + continue; } if (createOnly) {