From e36572e582cd6013519a9022883a261cf368d2d5 Mon Sep 17 00:00:00 2001 From: Predrag Ristic Date: Wed, 16 Sep 2026 14:56:51 +0000 Subject: [PATCH] fix(core): Return available build errors in one tool result (#38231) --- .../src/workspace/sandbox/run-in-sandbox.ts | 16 +-- .../assets/workflow-diagnostics.mts | 68 +++++++++ packages/@n8n/instance-ai/docs/sandboxing.md | 112 ++++++++++----- packages/@n8n/instance-ai/package.json | 5 +- .../__tests__/build-workflow.tool.test.ts | 95 ++++++++++++- ...low-source-diagnostics.integration.test.ts | 134 ++++++++++++++++++ .../workflow-source-diagnostics.test.ts | 95 +++++++++++++ .../tools/workflows/build-workflow.tool.ts | 37 ++++- .../workflows/workflow-source-diagnostics.ts | 57 ++++++++ .../workspace/__tests__/sandbox-setup.test.ts | 8 ++ .../__tests__/snapshot-manager.test.ts | 3 + .../src/workspace/sandbox-setup.ts | 31 ++-- .../src/workspace/sandbox-typescript.ts | 29 ++++ .../src/workspace/snapshot-manager.ts | 5 + packages/@n8n/instance-ai/tsconfig.json | 2 +- .../schema-validator-batching.test.ts | 100 +++++++++++++ .../node-parameter-schema/schema-validator.ts | 40 +++--- 17 files changed, 737 insertions(+), 100 deletions(-) create mode 100644 packages/@n8n/instance-ai/assets/workflow-diagnostics.mts create mode 100644 packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-diagnostics.integration.test.ts create mode 100644 packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-diagnostics.test.ts create mode 100644 packages/@n8n/instance-ai/src/tools/workflows/workflow-source-diagnostics.ts create mode 100644 packages/@n8n/instance-ai/src/workspace/sandbox-typescript.ts create mode 100644 packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator-batching.test.ts diff --git a/packages/@n8n/agents/src/workspace/sandbox/run-in-sandbox.ts b/packages/@n8n/agents/src/workspace/sandbox/run-in-sandbox.ts index aba7d13c47b..05f12f48e10 100644 --- a/packages/@n8n/agents/src/workspace/sandbox/run-in-sandbox.ts +++ b/packages/@n8n/agents/src/workspace/sandbox/run-in-sandbox.ts @@ -9,6 +9,8 @@ interface SandboxCommandResult { export interface RunInSandboxOptions { cwd?: string; abortSignal?: AbortSignal; + /** Command timeout in milliseconds. The provider enforces this limit. */ + timeout?: number; } export interface SandboxCommandTarget { @@ -17,12 +19,12 @@ export interface SandboxCommandTarget { executeCommand?: ( command: string, args?: string[], - options?: { cwd?: string; abortSignal?: AbortSignal }, + options?: RunInSandboxOptions, ) => Promise; processes?: { spawn: ( command: string, - options?: { cwd?: string; abortSignal?: AbortSignal }, + options?: RunInSandboxOptions, ) => Promise<{ wait: () => Promise }>; }; }; @@ -45,18 +47,12 @@ export async function runInSandbox( if (!sandbox) throw new Error('Workspace has no sandbox'); if (sandbox.executeCommand) { - const result = await sandbox.executeCommand(command, [], { - cwd: options.cwd, - abortSignal: options.abortSignal, - }); + const result = await sandbox.executeCommand(command, [], options); return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } if (sandbox.processes) { - const handle = await sandbox.processes.spawn(command, { - cwd: options.cwd, - abortSignal: options.abortSignal, - }); + const handle = await sandbox.processes.spawn(command, options); const result = await raceWithAbort(async () => await handle.wait(), options.abortSignal); return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } diff --git a/packages/@n8n/instance-ai/assets/workflow-diagnostics.mts b/packages/@n8n/instance-ai/assets/workflow-diagnostics.mts new file mode 100644 index 00000000000..454de03d8a5 --- /dev/null +++ b/packages/@n8n/instance-ai/assets/workflow-diagnostics.mts @@ -0,0 +1,68 @@ +import path from 'node:path'; +import type { Diagnostic } from 'typescript/unstable/async'; + +function formatMessage(diagnostic: Diagnostic, indent = ''): string { + return [ + indent + diagnostic.text, + ...(diagnostic.messageChain ?? []).map((child) => formatMessage(child, indent + ' ')), + ].join('\n'); +} + +// Check only the requested source and its imports. Do not execute the source. +async function main(): Promise { + // The sandbox installs this dependency from its own package manifest. + // eslint-disable-next-line import-x/no-extraneous-dependencies + const { API, DiagnosticCategory } = await import('typescript/unstable/async'); + const cwd = process.cwd(); + const configPath = path.join(cwd, `.workflow-diagnostics-${process.pid}.json`); + const config = JSON.stringify({ + extends: './tsconfig.json', + files: [path.resolve(process.argv[2])], + include: [], + }); + const api = new API({ + cwd, + fs: { + fileExists: (file) => (file === configPath ? true : undefined), + readFile: (file) => (file === configPath ? config : undefined), + }, + }); + try { + const snapshot = await api.updateSnapshot({ openProjects: [configPath] }); + const project = snapshot.getProject(configPath); + if (!project) throw new Error('Cannot open the workflow project'); + const program = project.program; + const diagnostics = [ + ...(await program.getSyntacticDiagnostics()), + ...(await program.getBindDiagnostics()), + ...(await program.getSemanticDiagnostics()), + ...(await program.getProgramDiagnostics()), + ...(await program.getGlobalDiagnostics()), + ]; + const errors: string[] = []; + for (const diagnostic of diagnostics) { + if (diagnostic.category !== DiagnosticCategory.Error) continue; + const message = `error TS${diagnostic.code}: ${formatMessage(diagnostic)}`; + if (!diagnostic.fileName) { + errors.push(message); + continue; + } + const file = path.relative(cwd, diagnostic.fileName); + if (file.startsWith('..' + path.sep) || file.split(path.sep).includes('node_modules')) + continue; + const source = await program.getSourceFile(diagnostic.fileName); + if (!source || source.isDeclarationFile) continue; + let location = file; + if (diagnostic.pos >= 0) { + const position = source.getLineAndCharacterOfPosition(diagnostic.pos); + location = `${file}(${position.line + 1},${position.character + 1})`; + } + errors.push(`${location}: ${message}`); + } + console.log(JSON.stringify([...new Set(errors)])); + } finally { + await api.close(); + } +} + +await main(); diff --git a/packages/@n8n/instance-ai/docs/sandboxing.md b/packages/@n8n/instance-ai/docs/sandboxing.md index 7662a8bcc35..87bfb4268df 100644 --- a/packages/@n8n/instance-ai/docs/sandboxing.md +++ b/packages/@n8n/instance-ai/docs/sandboxing.md @@ -110,18 +110,18 @@ entry that they already resolved. Initialization is lazy and idempotent. A marker file prevents repeated base setup. Knowledge-base content is refreshed when an existing sandbox is -reattached. The setup creates or materializes: +reattached. Initial setup creates or materializes: -| Path | Purpose | -|------|---------| -| `package.json` | Pinned `@n8n/workflow-sdk`, `tsx`, and Node type dependencies in normal mode | -| `tsconfig.json` | Strict TypeScript configuration | -| `build.mjs` | Workflow SDK execution and JSON conversion | -| `node-types/index.txt` | Searchable node-type catalog | -| `src/` | Workflow source files | -| `chunks/` | Reusable source modules | -| `knowledge-base/` | Best-practice, template, and SDK reference material | -| `.sandbox-initialized` | Setup marker | +| Path | Purpose | +| ---------------------- | ------------------------------------------------------------------------------------------ | +| `package.json` | Pinned `@n8n/workflow-sdk`, `tsx`, `typescript`, and Node type dependencies in normal mode | +| `tsconfig.json` | Strict TypeScript configuration | +| `build.mjs` | Workflow SDK execution and JSON conversion | +| `node-types/index.txt` | Searchable node-type catalog | +| `src/` | Workflow source files | +| `chunks/` | Reusable source modules | +| `knowledge-base/` | Best-practice, template, and SDK reference material | +| `.sandbox-initialized` | Setup marker | The Daytona image or versioned snapshot includes the stable workspace files and installed dependencies. The node-type catalog is written after sandbox @@ -151,6 +151,42 @@ node --import tsx build.mjs The tool then performs server-side workflow validation, resolves credentials, and saves the workflow through the backend service. +If source construction or source validation fails, the tool runs supplemental +TypeScript diagnostics after automatic import recovery. This pass checks the +requested file and its imports. It uses the sandbox compiler options and the +installed SDK declarations. It uses the pinned TypeScript 7.0.2 experimental +async API. Verify the API contract before upgrading. It does not check unrelated +workflow files or execute the source. Chat-model and node-group validation +failures do not start this pass. + +The package includes `assets/workflow-diagnostics.mts` as a source asset. +Setup copies it into new sandboxes as `workflow-diagnostics.mts`. +Snapshot builds include this file. The tool runs it with `node --import tsx`. +The worker extends the sandbox's `tsconfig.json` and selects the requested file. +Worker changes do not require a package build before testing from source. + +Initialized sandboxes keep their existing compiler and files. Sandboxes without +the worker or pinned compiler return the original build errors. They receive +supplemental diagnostics after they are recreated. + +The original errors appear first. Compiler findings include the file, line, +column, and TypeScript error code. Exact duplicates are removed. The compiler +runs in a sandbox Node process and starts a native compiler. The JavaScript heap +limit is 512 MB; this does not limit native compiler memory. The sandbox provider +enforces a five-second command timeout. A six-second host guard stops waiting if +the provider does not respond. Compiler failures and timeouts preserve the +original build errors. User cancellation still stops the build. + +The async API collects the native process exit status after normal completion. +In n8n sandbox image 1.3.0, forced termination can leave exited native processes +unreaped under PID 1. The provider stops execution, but its init process must +also collect these exit records. + +Successful builds do not run this pass. A source construction error prevents +graph validation, so this pass cannot report graph checks that did not run. +Node schema errors include all available findings from the selected variant. +Discriminator errors retain the guidance for valid variants. + For WorkflowJSON source, the tool parses the JSON directly and then applies the same server-side save controls. There is no host-side TypeScript build fallback when the sandbox is unavailable. @@ -173,15 +209,15 @@ Sandbox operations use the active Instance AI trace. Each span inherits the thread metadata. The sandbox does not retain a turn's trace handle. Skill preparation runs inside the agent's lazy build and uses the same trace context. -| Operation | Trace data | -|-----------|------------| -| Acquire and start | Provider, sandbox ID, cached reuse, shared acquisition, duration, and errors | -| Initialize workspace | Marker check, node catalog, base files, dependency installation, and optional SDK linking | -| Sync skills and knowledge base | Bundle hash, file counts, byte counts, reuse or upload, and manifest check result | -| Read, write, and edit files | File operations under the existing tool span, paths, byte counts, and errors | +| Operation | Trace data | +| -------------------------------------- | ----------------------------------------------------------------------------------------- | +| Acquire and start | Provider, sandbox ID, cached reuse, shared acquisition, duration, and errors | +| Initialize workspace | Marker check, node catalog, base files, dependency installation, and optional SDK linking | +| Sync skills and knowledge base | Bundle hash, file counts, byte counts, reuse or upload, and manifest check result | +| Read, write, and edit files | File operations under the existing tool span, paths, byte counts, and errors | | Execute commands and compile workflows | Command size, exit code, timeout or cancellation, compile result, and bounded diagnostics | -| Retry and fallback | File path, failed attempt, retry delay, and command fallback | -| Evict cache and destroy | Reason, provider, sandbox ID, and cleanup errors | +| Retry and fallback | File path, failed attempt, retry delay, and command fallback | +| Evict cache and destroy | Reason, provider, sandbox ID, and cleanup errors | Batch operations reject on the first failure. Their spans end at that point. Transfers that already started can continue. Batch spans omit individual successful @@ -210,24 +246,24 @@ provider notifications or polling. ## Configuration -| Variable | Default | Purpose | -|----------|---------|---------| -| `N8N_INSTANCE_AI_SANDBOX_ENABLED` | `false` | Enable the sandbox-backed workspace | -| `N8N_INSTANCE_AI_SANDBOX_PROVIDER` | `n8n-sandbox` | Select `n8n-sandbox` or `daytona` | -| `N8N_SANDBOX_SERVICE_URL` | empty | n8n sandbox service URL | -| `N8N_SANDBOX_SERVICE_API_KEY` | empty | n8n sandbox service API key | -| `DAYTONA_API_URL` | empty | Daytona API URL | -| `DAYTONA_API_KEY` | empty | Daytona API key for direct mode | -| `N8N_INSTANCE_AI_SANDBOX_IMAGE` | `daytonaio/sandbox:0.5.0` | Daytona base image | -| `N8N_INSTANCE_AI_SANDBOX_SNAPSHOT` | empty | Daytona proxy snapshot override | -| `N8N_INSTANCE_AI_SANDBOX_TIMEOUT` | `300000` | Default command timeout in milliseconds | -| `N8N_INSTANCE_AI_BUILDER_SANDBOX_TTL_MS` | `900000` | In-process idle cache TTL; `0` disables eviction | -| `N8N_INSTANCE_AI_SANDBOX_NAME_PREFIX` | empty | Prefix and label for Daytona names | -| `N8N_INSTANCE_AI_SANDBOX_EPHEMERAL` | `false` | Delete the sandbox once idle instead of stopping it | -| `N8N_INSTANCE_AI_SANDBOX_AUTO_STOP_MINUTES` | `15` | Daytona idle time before stop; `0` disables auto-stop | -| `N8N_INSTANCE_AI_SANDBOX_AUTO_ARCHIVE_MINUTES` | `60` | Daytona stopped time before archive; `0` uses its maximum | -| `N8N_INSTANCE_AI_SANDBOX_AUTO_DELETE_MINUTES` | `10080` | Daytona stopped time before delete; negative disables and `0` deletes on stop | -| `N8N_INSTANCE_AI_DAYTONA_TOKEN_REFRESH_SKEW_MS` | `300000` | Proxy-token refresh skew | -| `N8N_INSTANCE_AI_SANDBOX_LINK_SDK` | `false` | Install local workspace packages for development | +| Variable | Default | Purpose | +| ----------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------- | +| `N8N_INSTANCE_AI_SANDBOX_ENABLED` | `false` | Enable the sandbox-backed workspace | +| `N8N_INSTANCE_AI_SANDBOX_PROVIDER` | `n8n-sandbox` | Select `n8n-sandbox` or `daytona` | +| `N8N_SANDBOX_SERVICE_URL` | empty | n8n sandbox service URL | +| `N8N_SANDBOX_SERVICE_API_KEY` | empty | n8n sandbox service API key | +| `DAYTONA_API_URL` | empty | Daytona API URL | +| `DAYTONA_API_KEY` | empty | Daytona API key for direct mode | +| `N8N_INSTANCE_AI_SANDBOX_IMAGE` | `daytonaio/sandbox:0.5.0` | Daytona base image | +| `N8N_INSTANCE_AI_SANDBOX_SNAPSHOT` | empty | Daytona proxy snapshot override | +| `N8N_INSTANCE_AI_SANDBOX_TIMEOUT` | `300000` | Default command timeout in milliseconds | +| `N8N_INSTANCE_AI_BUILDER_SANDBOX_TTL_MS` | `900000` | In-process idle cache TTL; `0` disables eviction | +| `N8N_INSTANCE_AI_SANDBOX_NAME_PREFIX` | empty | Prefix and label for Daytona names | +| `N8N_INSTANCE_AI_SANDBOX_EPHEMERAL` | `false` | Delete the sandbox once idle instead of stopping it | +| `N8N_INSTANCE_AI_SANDBOX_AUTO_STOP_MINUTES` | `15` | Daytona idle time before stop; `0` disables auto-stop | +| `N8N_INSTANCE_AI_SANDBOX_AUTO_ARCHIVE_MINUTES` | `60` | Daytona stopped time before archive; `0` uses its maximum | +| `N8N_INSTANCE_AI_SANDBOX_AUTO_DELETE_MINUTES` | `10080` | Daytona stopped time before delete; negative disables and `0` deletes on stop | +| `N8N_INSTANCE_AI_DAYTONA_TOKEN_REFRESH_SKEW_MS` | `300000` | Proxy-token refresh skew | +| `N8N_INSTANCE_AI_SANDBOX_LINK_SDK` | `false` | Install local workspace packages for development | See [Configuration](configuration.md) for the complete environment reference. diff --git a/packages/@n8n/instance-ai/package.json b/packages/@n8n/instance-ai/package.json index abe716823ee..90d1ca664d5 100644 --- a/packages/@n8n/instance-ai/package.json +++ b/packages/@n8n/instance-ai/package.json @@ -6,8 +6,8 @@ "typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json", "build": "tsc -p ./tsconfig.build.json && tsc-alias -p tsconfig.build.json", "build:unchecked": "tsc -p ./tsconfig.build.json --noCheck && tsc-alias -p tsconfig.build.json", - "format": "biome format --write src", - "format:check": "biome ci src", + "format": "biome format --write src assets", + "format:check": "biome ci src assets", "test": "vitest run", "test:unit": "vitest run", "test:dev": "vitest --silent=false", @@ -30,6 +30,7 @@ "module": "src/index.ts", "types": "dist/index.d.ts", "files": [ + "assets/workflow-diagnostics.mts", "dist/**/*", "knowledge-base/**/*", "skills/**/*" diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts index 80b54a53c00..e27fd34a574 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts @@ -20,6 +20,7 @@ import { analyzeWorkflow } from '../setup-workflow.service'; import { getWorkflowSourceFileBinding, hashWorkflowSource } from '../workflow-file-bindings'; import { ensureWebhookIds } from '../workflow-json-utils'; import { compileWorkflowSource } from '../workflow-source-compiler'; +import { appendWorkflowSourceDiagnostics } from '../workflow-source-diagnostics'; import { partitionWarnings, type ValidationWarning } from '../workflow-validation-warnings'; // Passthrough spy: real behavior (handle-fallback path in unit env), observable calls. @@ -58,6 +59,12 @@ const generatedWorkflow = { connections: {}, }; +vi.mock('../workflow-source-diagnostics', () => ({ + appendWorkflowSourceDiagnostics: vi.fn( + async (_context, _filePath, errors: string[]) => await Promise.resolve(errors), + ), +})); + vi.mock('../workflow-source-compiler', () => ({ compileWorkflowSource: vi.fn(), })); @@ -219,6 +226,9 @@ function workflowSourceBuildFailure(error: string) { describe('createBuildWorkflowTool', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(appendWorkflowSourceDiagnostics).mockImplementation( + async (_context, _filePath, errors) => await Promise.resolve(errors), + ); vi.mocked(compileWorkflowSource).mockResolvedValue({ success: true, workflow: structuredClone(generatedWorkflow), @@ -669,6 +679,7 @@ describe('createBuildWorkflowTool', () => { expect(result.errors?.join('\n')).toContain('[GROUPING_DECISION_MISSING]'); expect(result.errors?.join('\n')).toContain('Step 0, Step 1'); expect(result.remediation?.reason).toBe('workflow_grouping_decision_missing'); + expect(appendWorkflowSourceDiagnostics).not.toHaveBeenCalled(); expect(result.grouping).toMatchObject({ groupCount: 0, decision: 'missing' }); expect(context.workflowService.createFromWorkflowJSON).not.toHaveBeenCalled(); expect(trackTelemetry).toHaveBeenCalledWith( @@ -752,6 +763,7 @@ describe('createBuildWorkflowTool', () => { // The error carries the drop reason; the matching warning is not repeated. expect(result.warnings?.join('\n') ?? '').not.toContain('[NODE_GROUP_DROPPED]'); expect(result.remediation?.reason).toBe('workflow_group_dropped_over_ceiling'); + expect(appendWorkflowSourceDiagnostics).not.toHaveBeenCalled(); expect(result.grouping).toMatchObject({ groupCount: 0, decision: 'grouped' }); expect(context.workflowService.createFromWorkflowJSON).not.toHaveBeenCalled(); }); @@ -1349,6 +1361,7 @@ describe('createBuildWorkflowTool', () => { expect(result).toMatchObject({ success: true, workflowId: 'wf-bound' }); expect(result.warnings?.some((w) => w.includes('pre-existing node'))).toBe(true); + expect(appendWorkflowSourceDiagnostics).not.toHaveBeenCalled(); }); it('still fails the build on blocking findings for nodes the build changed', async () => { @@ -2673,8 +2686,32 @@ describe('createBuildWorkflowTool', () => { expect((result.warnings ?? []).join('\n')).not.toContain('chat_model_provider_mismatch'); }); - it('returns source file metadata on validation failures', async () => { + it('skips supplemental diagnostics on a sandbox failure', async () => { + const { context, filePath } = makeContext({}); + vi.mocked(compileWorkflowSource).mockResolvedValueOnce({ + success: false, + reason: 'workflow_source_sandbox_unavailable', + editable: false, + errors: ['Sandbox unavailable'], + summary: 'Sandbox unavailable', + }); + const result = await executeTool(createBuildWorkflowTool(context), { + filePath, + }); + expect(result.success).toBe(false); + expect(result.errors).toEqual(['Sandbox unavailable']); + expect(appendWorkflowSourceDiagnostics).not.toHaveBeenCalled(); + }); + + it('returns source file metadata and compiler findings on validation failures', async () => { const { context, filePath } = makeContext({ source: 'invalid source' }); + vi.mocked(appendWorkflowSourceDiagnostics).mockImplementationOnce( + async (_context, _path, errors) => + await Promise.resolve([ + ...errors, + '[TS2322] src/workflows/main.workflow.ts:4:1: Type mismatch', + ]), + ); vi.mocked(compileWorkflowSource).mockResolvedValueOnce({ success: true, workflow: { name: 'Generated workflow', nodes: [], connections: {} }, @@ -2699,11 +2736,44 @@ describe('createBuildWorkflowTool', () => { shouldEdit: true, reason: 'workflow_source_validation_failed', }, + errors: [ + '[UNKNOWN_CONFIG_KEY]: Unknown config key "recipient"', + '[TS2322] src/workflows/main.workflow.ts:4:1: Type mismatch', + ], }); expect(context.workflowService.createFromWorkflowJSON).not.toHaveBeenCalled(); }); - it('keeps repeated validation-error escalation generic', async () => { + it('returns chat-model validation errors without supplemental source diagnostics', async () => { + const { context, filePath } = makeContext({}); + vi.mocked(partitionWarnings) + .mockReturnValueOnce({ blocking: [], informational: [] }) + .mockReturnValueOnce({ + blocking: [ + { + code: 'chat_model_validation', + nodeName: 'Chat Model', + message: 'The credential cannot use this model.', + severity: 'error', + }, + ], + informational: [], + }); + + const result = await executeTool(createBuildWorkflowTool(context), { + filePath, + }); + + expect(result).toMatchObject({ + success: false, + errors: ['[chat_model_validation] (Chat Model): The credential cannot use this model.'], + remediation: { reason: 'chat_model_validation_failed' }, + }); + expect(appendWorkflowSourceDiagnostics).not.toHaveBeenCalled(); + expect(context.workflowService.createFromWorkflowJSON).not.toHaveBeenCalled(); + }); + + it('keeps repeated validation-error escalation stable when diagnostics are unavailable', async () => { const { context, filePath } = makeContext({ source: 'workflow source' }); const validationResult = { success: true as const, @@ -2722,6 +2792,10 @@ describe('createBuildWorkflowTool', () => { .mockReturnValueOnce(partitionedWarnings) .mockReturnValueOnce(partitionedWarnings); + vi.mocked(appendWorkflowSourceDiagnostics).mockImplementationOnce( + async (_context, _filePath, errors) => + await Promise.resolve([...errors, '[TS2322] src/main.ts:1:1: Type mismatch']), + ); const tool = createBuildWorkflowTool(context); await executeTool<{ success: boolean; errors?: string[] }>(tool, { filePath }); @@ -2875,6 +2949,9 @@ describe('autoImportMissingSdkSymbols', () => { describe('auto-import recovery on compile failure', () => { beforeEach(async () => { vi.clearAllMocks(); + vi.mocked(appendWorkflowSourceDiagnostics).mockImplementation( + async (_context, _filePath, errors) => await Promise.resolve(errors), + ); // Real classifier: guards that auto_imported_sdk_symbols stays informational. const actual = await vi.importActual<{ partitionWarnings: typeof partitionWarnings }>( '../workflow-validation-warnings', @@ -2911,6 +2988,7 @@ describe('auto-import recovery on compile failure', () => { 'Auto-added missing @n8n/workflow-sdk import(s): expr', ); expect(compileWorkflowSource).toHaveBeenCalledTimes(2); + expect(appendWorkflowSourceDiagnostics).not.toHaveBeenCalled(); }); it('returns the retried errors when the recovery retry still fails', async () => { @@ -2920,6 +2998,17 @@ describe('auto-import recovery on compile failure', () => { vi.mocked(compileWorkflowSource) .mockResolvedValueOnce(workflowSourceBuildFailure('ReferenceError: expr is not defined')) .mockResolvedValueOnce(workflowSourceBuildFailure("Cannot find name 'unrelated'")); + vi.mocked(appendWorkflowSourceDiagnostics).mockImplementationOnce( + async (_context, path, errors) => { + await Promise.resolve(); + expect(files.get(path)).toContain("import { expr } from '@n8n/workflow-sdk';"); + expect(errors).toEqual(["Cannot find name 'unrelated'"]); + return [ + ...errors, + '[TS2304] src/workflows/main.workflow.ts:2:1: Cannot find name unrelated', + ]; + }, + ); const result = await executeTool(createBuildWorkflowTool(context), { filePath, @@ -2930,6 +3019,8 @@ describe('auto-import recovery on compile failure', () => { // Errors describe the persisted (import-injected) file, not the original source. expect(result.errors?.join('\n')).toContain("Cannot find name 'unrelated'"); expect(result.errors?.join('\n')).not.toContain('expr is not defined'); + expect(result.errors?.join('\n')).toContain('[TS2304]'); + expect(appendWorkflowSourceDiagnostics).toHaveBeenCalledTimes(1); expect(files.get(filePath)).toContain("import { expr } from '@n8n/workflow-sdk';"); expect(compileWorkflowSource).toHaveBeenCalledTimes(2); }); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-diagnostics.integration.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-diagnostics.integration.test.ts new file mode 100644 index 00000000000..de91204a245 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-diagnostics.integration.test.ts @@ -0,0 +1,134 @@ +import { jsonParse } from 'n8n-workflow'; +import { execFile } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, writeFile, symlink, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + +import { + loadWorkflowDiagnosticsWorker, + SANDBOX_TYPESCRIPT_VERSION, + TSCONFIG_JSON, + WORKFLOW_DIAGNOSTICS_FILENAME, +} from '../../../workspace/sandbox-typescript'; + +const exec = promisify(execFile); + +describe('sandbox TypeScript diagnostics', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'workflow-diagnostics-')); + await mkdir(join(root, 'node_modules/@n8n'), { recursive: true }); + await mkdir(join(root, 'src')); + await writeFile( + join(root, WORKFLOW_DIAGNOSTICS_FILENAME), + await loadWorkflowDiagnosticsWorker(), + ); + await writeFile(join(root, 'package.json'), JSON.stringify({ type: 'module' })); + await writeFile(join(root, 'tsconfig.json'), TSCONFIG_JSON); + await symlink(dirname(require.resolve('tsx/package.json')), join(root, 'node_modules/tsx')); + await mkdir(join(root, 'node_modules/@types')); + await symlink( + dirname(require.resolve('@types/node/package.json')), + join(root, 'node_modules/@types/node'), + ); + const packageRoot = dirname(require.resolve('typescript/package.json')); + expect( + jsonParse<{ version: string }>(await readFile(join(packageRoot, 'package.json'), 'utf8')) + .version, + ).toBe(SANDBOX_TYPESCRIPT_VERSION); + await symlink(packageRoot, join(root, 'node_modules/typescript')); + await symlink( + dirname(require.resolve('@n8n/workflow-sdk/package.json')), + join(root, 'node_modules/@n8n/workflow-sdk'), + ); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + async function diagnose(source: string) { + await writeFile(join(root, 'src/main.ts'), source); + const { stdout } = await exec( + process.execPath, + ['--import', 'tsx', WORKFLOW_DIAGNOSTICS_FILENAME, './src/main.ts'], + { cwd: root, timeout: 10_000 }, + ); + return jsonParse(stdout); + } + + it('returns both syntax errors in one result', async () => { + const diagnostics = await diagnose(`declare function tool(config: unknown): unknown; +const first = tool({ config: { parameters: { value: true } }); +const second = tool({ config: { parameters: { value: true } });`); + expect(diagnostics).toEqual([ + expect.stringMatching(/^src\/main.ts\(2,\d+\): error TS1005:/), + expect.stringMatching(/^src\/main.ts\(3,\d+\): error TS1005:/), + ]); + }); + + it('returns SDK method errors and imported syntax and type errors together', async () => { + await writeFile( + join(root, 'src/chunk.ts'), + "export const count: number = 'three';\nexport const broken = { value: };", + ); + await writeFile(join(root, 'src/unrelated.ts'), "const unrelated: number = 'ignore';"); + const diagnostics = await diagnose(`import { workflow } from '@n8n/workflow-sdk'; +import { count } from './chunk'; +const wf = workflow('id', 'name'); +wf.onError(); +wf.onDone(); +export default wf;`); + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.stringContaining('src/chunk.ts(1,14): error TS2322:'), + expect.stringContaining('src/chunk.ts(2,32): error TS1109:'), + expect.stringContaining('src/main.ts(4,4): error TS2554: Expected 1 arguments, but got 0.'), + expect.stringContaining("src/main.ts(5,4): error TS2339: Property 'onDone'"), + ]), + ); + expect(diagnostics).toHaveLength(4); + }); + + it('returns program errors once alongside source errors', async () => { + await rm(join(root, 'node_modules/@types/node')); + + const diagnostics = await diagnose("export const count: number = 'three';"); + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^error TS2688: Cannot find type definition file for 'node'\./), + expect.stringContaining('src/main.ts(1,14): error TS2322:'), + ]), + ); + expect(diagnostics).toHaveLength(2); + }); + + it('accepts supported SDK methods', async () => { + const diagnostics = await diagnose(`import { workflow, node } from '@n8n/workflow-sdk'; +const first = node({ type: 'n8n-nodes-base.noOp', version: 1, config: {} }); +const failure = node({ type: 'n8n-nodes-base.noOp', version: 1, config: {} }); +export default workflow('id', 'name').add(first.onError(failure));`); + expect(diagnostics).toEqual([]); + }); + + it('preserves positions after Unicode characters and nested error messages', async () => { + const source = `const emoji = '😀'; const count: number = 'three'; +declare const actual: { nested: { value: string } }; +const expected: { nested: { value: number } } = actual;`; + const diagnostics = await diagnose(source); + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.stringContaining(`src/main.ts(1,${source.indexOf('count') + 1}): error TS2322:`), + expect.stringMatching(/src\/main.ts\(3,\d+\): error TS2322:[\s\S]*\n.*nested.value/), + ]), + ); + }); + + it('accepts Node globals', async () => { + expect(await diagnose("export const value = Buffer.from(process.env.VALUE ?? '');")).toEqual( + [], + ); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-diagnostics.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-diagnostics.test.ts new file mode 100644 index 00000000000..3c505c9ab20 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-diagnostics.test.ts @@ -0,0 +1,95 @@ +import { createAbortError } from '@n8n/agents'; +import { mock } from 'vitest-mock-extended'; + +import type { InstanceAiContext } from '../../../types'; +import { runInSandbox } from '../../../workspace/sandbox-fs'; +import { appendWorkflowSourceDiagnostics } from '../workflow-source-diagnostics'; + +vi.mock('@n8n/agents/sandbox', () => ({ + getWorkspaceRoot: async () => await Promise.resolve('/sandbox'), +})); +vi.mock('../../../workspace/sandbox-fs', async (importOriginal) => ({ + ...(await importOriginal()), + runInSandbox: vi.fn(), +})); + +const context = mock({ + workspace: mock>(), + logger: mock(), +}); +const original = ['Original runtime error']; +const diagnostic = + "src/main.ts(2,4): error TS2339: Property 'onError' does not exist on type 'WorkflowBuilder'."; + +describe('appendWorkflowSourceDiagnostics', () => { + afterEach(() => { + vi.resetAllMocks(); + vi.useRealTimers(); + }); + + it('keeps original errors first and removes only exact duplicates', async () => { + vi.mocked(runInSandbox).mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([diagnostic, diagnostic, diagnostic.replace('(2,4)', '(3,4)')]), + stderr: '', + }); + const errors = await appendWorkflowSourceDiagnostics(context, 'src/main.ts', original); + expect(errors).toEqual([...original, diagnostic, diagnostic.replace('(2,4)', '(3,4)')]); + }); + + it.each([ + { exitCode: 1, stdout: '', stderr: 'Compiler unavailable' }, + { exitCode: 0, stdout: 'invalid JSON', stderr: '' }, + { exitCode: 0, stdout: '[{"code":2339}]', stderr: '' }, + ])('preserves the build error if diagnostics fail: %j', async (result) => { + vi.mocked(runInSandbox).mockResolvedValue(result); + expect(await appendWorkflowSourceDiagnostics(context, 'src/main.ts', original)).toEqual( + original, + ); + }); + + it('preserves the build error when the sandbox is unavailable', async () => { + vi.mocked(runInSandbox).mockRejectedValue(new Error('Disconnected')); + expect(await appendWorkflowSourceDiagnostics(context, 'src/main.ts', original)).toEqual( + original, + ); + }); + + it('removes duplicate validation findings even when the compiler is unavailable', async () => { + vi.mocked(runInSandbox).mockRejectedValue(new Error('Disconnected')); + expect( + await appendWorkflowSourceDiagnostics(context, 'src/main.ts', [...original, ...original]), + ).toEqual(original); + }); + + it('skips JSON source', async () => { + expect(await appendWorkflowSourceDiagnostics(context, 'src/main.json', original)).toEqual( + original, + ); + expect(runInSandbox).not.toHaveBeenCalled(); + }); + + it('stops waiting when the diagnostic request times out', async () => { + vi.useFakeTimers(); + vi.mocked(runInSandbox).mockImplementation(async () => await new Promise(() => {})); + const result = appendWorkflowSourceDiagnostics(context, 'src/main.ts', original); + await vi.advanceTimersByTimeAsync(6_000); + expect(await result).toEqual(original); + expect(runInSandbox).toHaveBeenCalledWith( + context.workspace, + "exec node --max-old-space-size=512 --import tsx workflow-diagnostics.mts '/sandbox/src/main.ts'", + expect.objectContaining({ timeout: 5_000 }), + ); + }); + + it('propagates user cancellation', async () => { + const controller = new AbortController(); + vi.mocked(runInSandbox).mockImplementation(() => { + controller.abort(); + throw createAbortError(); + }); + await expect( + appendWorkflowSourceDiagnostics(context, 'src/main.ts', original, controller.signal), + ).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts index 8cdf4a5263f..7ca0de89d8a 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts @@ -79,6 +79,7 @@ import { } from './workflow-json-utils'; import { computeChangedNodeNames, downgradeUnchangedNodeBlockers } from './workflow-node-diff'; import { compileWorkflowSource } from './workflow-source-compiler'; +import { appendWorkflowSourceDiagnostics } from './workflow-source-diagnostics'; import { GROUP_DROPPED_OVER_CEILING_CODE, groupingDecisionBlocker, @@ -454,6 +455,7 @@ async function directPostBuildFlowHandoff( } interface ValidationFailureArgs { + abortSignal?: AbortSignal; context: InstanceAiContext; blocking: ValidationWarning[]; informational: ValidationWarning[]; @@ -470,7 +472,7 @@ interface ValidationFailureArgs { owner: WorkflowBuildOutcome['owner']; isSupportingWorkflow?: boolean; isAuxiliarySupportingWorkflow?: boolean; - withEscalation: (errors: string[]) => string[]; + withEscalation: (errors: string[], options?: { trackingErrors?: string[] }) => string[]; stage?: BuildTelemetryStage; grouping?: GroupingOutcome; } @@ -531,8 +533,14 @@ async function handleValidationFailure(args: ValidationFailureArgs) { grouping, } = args; + const validationErrors = blocking.map( + (e) => `[${e.code}]${e.nodeName ? ` (${e.nodeName})` : ''}: ${e.message}`, + ); const formattedErrors = withEscalation( - blocking.map((e) => `[${e.code}]${e.nodeName ? ` (${e.nodeName})` : ''}: ${e.message}`), + reason === 'workflow_source_validation_failed' + ? await appendWorkflowSourceDiagnostics(context, filePath, validationErrors, args.abortSignal) + : validationErrors, + { trackingErrors: validationErrors }, ); const remediation = createCodeFixableRemediation({ reason, guidance }); const binding = await markSourceBuildFailed(context, initialBinding, sourceHash); @@ -999,9 +1007,14 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { } const withEscalation = ( errors: string[], - options: { includeSdkLanguageGuidance?: boolean } = {}, + options: { includeSdkLanguageGuidance?: boolean; trackingErrors?: string[] } = {}, ): string[] => { - const escalation = failureTracker.record(workItemKey, errors, options); + // Supplemental diagnostics can time out. Keep the original failure signature stable. + const escalation = failureTracker.record( + workItemKey, + options.trackingErrors ?? errors, + options, + ); return escalation ? [...errors, escalation] : errors; }; @@ -1052,7 +1065,18 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { } } if (!compiled.success) { - const errors = compiled.editable ? withEscalation(compiled.errors) : compiled.errors; + const buildErrors = + compiled.reason === 'workflow_source_build_failed' + ? await appendWorkflowSourceDiagnostics( + context, + filePath, + compiled.errors, + ctx.abortSignal, + ) + : compiled.errors; + const errors = compiled.editable + ? withEscalation(buildErrors, { trackingErrors: compiled.errors }) + : buildErrors; const remediation = createSourceCompileRemediation({ reason: compiled.reason, editable: compiled.editable, @@ -1108,6 +1132,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { if (partitionedWarnings.blocking.length > 0) { return await handleValidationFailure({ + abortSignal: ctx.abortSignal, context, blocking: partitionedWarnings.blocking, informational, @@ -1223,6 +1248,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { if (partitionedChatModelWarnings.blocking.length > 0) { return await handleValidationFailure({ + abortSignal: ctx.abortSignal, context, blocking: partitionedChatModelWarnings.blocking, informational, @@ -1333,6 +1359,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { : informational; return await handleValidationFailure({ + abortSignal: ctx.abortSignal, context, blocking: [blocker], informational: informationalWithoutDrops, diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-diagnostics.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-diagnostics.ts new file mode 100644 index 00000000000..786fc840858 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-diagnostics.ts @@ -0,0 +1,57 @@ +import { isAbortError, raceWithAbort, throwIfAborted } from '@n8n/agents'; +import { getWorkspaceRoot } from '@n8n/agents/sandbox'; +import { z } from 'zod'; + +import { isTypeScriptWorkflowSource } from './workflow-source-compiler'; +import type { InstanceAiContext } from '../../types'; +import { escapeSingleQuotes, runInSandbox } from '../../workspace/sandbox-fs'; +import { WORKFLOW_DIAGNOSTICS_FILENAME } from '../../workspace/sandbox-typescript'; +import { joinWorkspacePath } from '../../workspace/workspace-paths'; + +const DIAGNOSTIC_TIMEOUT_MS = 5_000; + +/** Append available compiler findings without changing the original build failure. */ +export async function appendWorkflowSourceDiagnostics( + context: InstanceAiContext, + filePath: string, + buildErrors: string[], + abortSignal?: AbortSignal, +): Promise { + throwIfAborted(abortSignal); + const errors = [...new Set(buildErrors)]; + if (!isTypeScriptWorkflowSource(filePath) || !context.workspace) return errors; + + const timeout = new AbortController(); + const timer = setTimeout(() => timeout.abort(), DIAGNOSTIC_TIMEOUT_MS + 1_000); + const diagnosticSignal = abortSignal + ? AbortSignal.any([abortSignal, timeout.signal]) + : timeout.signal; + try { + const workspace = context.workspace; + const result = await raceWithAbort(async () => { + const root = await getWorkspaceRoot(workspace); + throwIfAborted(diagnosticSignal); + const sourcePath = joinWorkspacePath(root, filePath); + return await runInSandbox( + workspace, + `exec node --max-old-space-size=512 --import tsx ${WORKFLOW_DIAGNOSTICS_FILENAME} '${escapeSingleQuotes(sourcePath)}'`, + { cwd: root, abortSignal: diagnosticSignal, timeout: DIAGNOSTIC_TIMEOUT_MS }, + ); + }, diagnosticSignal); + throwIfAborted(abortSignal); + if (result.exitCode !== 0) return errors; + + const parsed: unknown = JSON.parse(result.stdout); + const diagnostics = z.array(z.string()).parse(parsed); + return [...new Set([...errors, ...diagnostics])]; + } catch (error) { + throwIfAborted(abortSignal); + if (isAbortError(error) && !timeout.signal.aborted) throw error; + context.logger.debug('Supplemental workflow diagnostics unavailable', { + error: error instanceof Error ? error.message : String(error), + }); + return errors; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts index 1555c02f84e..99da761de56 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts @@ -264,6 +264,7 @@ describe('PACKAGE_JSON', () => { expect(packageJson.dependencies['@n8n/workflow-sdk']).toBeDefined(); expect(packageJson.dependencies.tsx).toBeDefined(); + expect(packageJson.dependencies.typescript).toBe('7.0.2'); }); it('should omit the registry SDK dependency when workspace SDK linking is enabled', async () => { @@ -271,6 +272,7 @@ describe('PACKAGE_JSON', () => { expect(packageJson.dependencies).not.toHaveProperty('@n8n/workflow-sdk'); expect(packageJson.dependencies.tsx).toBeDefined(); + expect(packageJson.dependencies.typescript).toBe('7.0.2'); }); }); /** npm install commands issued, ignoring how cwd/options were passed. */ @@ -308,6 +310,11 @@ describe('setupSandboxWorkspace', () => { >(async () => await Promise.resolve()); await setupSandboxWorkspace(createFilesystemWorkspace(writeFile), createSetupContext()); + expect(writeFile).toHaveBeenCalledWith( + '/home/daytona/workspace/workflow-diagnostics.mts', + expect.any(String), + { recursive: true }, + ); const markerCallIndex = writeFile.mock.calls.findIndex( ([path]) => path === '/home/daytona/workspace/.sandbox-initialized', @@ -391,6 +398,7 @@ describe('setupSandboxWorkspace', () => { expect(initialized).toBe(false); expect(installCommandsFrom(runInSandbox)).toEqual([]); const writtenPaths = writeFile.mock.calls.map(([path]) => path); + expect(writtenPaths).not.toContain('/sandbox/workflow-diagnostics.mts'); expect(writtenPaths.some((p) => p.includes('/knowledge-base/templates/'))).toBe(true); }); diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts index 1313bd147a5..58ee9c796ba 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/snapshot-manager.test.ts @@ -202,6 +202,9 @@ describe('SnapshotManager.ensureImage', () => { const stagingDir = image.contextList[0]?.sourcePath; expect(stagingDir).toBeDefined(); + await expect(readFile(join(stagingDir, 'workflow-diagnostics.mts'), 'utf8')).resolves.toContain( + 'typescript/unstable/async', + ); expect(stagingDir).toContain('n8n-snapshot-context-1.123.0'); await expect( readFile(join(stagingDir, 'skills/data-table-manager/SKILL.md'), 'utf-8'), diff --git a/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts b/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts index a3cff890b14..cae329679c8 100644 --- a/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts +++ b/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts @@ -46,10 +46,18 @@ import { type SandboxWorkspace, writeFileViaSandbox, } from './sandbox-fs'; +import { + loadWorkflowDiagnosticsWorker, + SANDBOX_TYPESCRIPT_VERSION, + TSCONFIG_JSON, + WORKFLOW_DIAGNOSTICS_FILENAME, +} from './sandbox-typescript'; import { joinWorkspacePath } from './workspace-paths'; import { materializeKnowledgeBaseIntoWorkspace } from '../knowledge-base/materialize-knowledge-base'; import { traceSandboxOperation, sandboxFileBytes } from '../tracing/sandbox-tracing'; +export { TSCONFIG_JSON } from './sandbox-typescript'; + const hostRequire = createRequire(__filename); type SandboxWorkspaceSetupStep = @@ -189,6 +197,7 @@ const SANDBOX_TYPES_NODE_VERSION = '24.10.1'; function buildPackageJson(sdkSpecifier: string | null): string { const dependencies: Record = { tsx: SANDBOX_TSX_VERSION, + typescript: SANDBOX_TYPESCRIPT_VERSION, }; if (sdkSpecifier) { dependencies['@n8n/workflow-sdk'] = sdkSpecifier; @@ -309,27 +318,6 @@ try { } `; -export const TSCONFIG_JSON = JSON.stringify( - { - compilerOptions: { - strict: true, - // Disable strictNullChecks because the SDK's ifElse() returns NodeInstance - // where onTrue?/onFalse? are optional in the type (they're always present at runtime). - // Without this, tsc rejects `.onTrue()` / `.onFalse()` calls. - strictNullChecks: false, - noEmit: true, - target: 'ES2022', - module: 'ES2022', - moduleResolution: 'bundler', - esModuleInterop: true, - skipLibCheck: true, - }, - include: ['src/**/*.ts', 'chunks/**/*.ts'], - }, - null, - 2, -); - /** * Build a searchable catalog line for a node type. * Format: nodeType | displayName | description | version | aliases: ... @@ -522,6 +510,7 @@ export async function setupSandboxWorkspace( files.set('package.json', PACKAGE_JSON); files.set('tsconfig.json', TSCONFIG_JSON); files.set('build.mjs', BUILD_MJS); + files.set(WORKFLOW_DIAGNOSTICS_FILENAME, await loadWorkflowDiagnosticsWorker()); // Node types catalog const nodeTypes = await setupStep( diff --git a/packages/@n8n/instance-ai/src/workspace/sandbox-typescript.ts b/packages/@n8n/instance-ai/src/workspace/sandbox-typescript.ts new file mode 100644 index 00000000000..43c82195c79 --- /dev/null +++ b/packages/@n8n/instance-ai/src/workspace/sandbox-typescript.ts @@ -0,0 +1,29 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +// Pin the experimental API. Verify its contract before upgrading. +export const SANDBOX_TYPESCRIPT_VERSION = '7.0.2'; + +const TSCONFIG = { + compilerOptions: { + strict: true, + // The SDK types mark onTrue/onFalse as optional. They are present at runtime. + strictNullChecks: false, + noEmit: true, + target: 'ES2022', + types: ['node'], + module: 'ES2022', + moduleResolution: 'bundler', + esModuleInterop: true, + skipLibCheck: true, + }, + include: ['src/**/*.ts', 'chunks/**/*.ts'], +}; + +export const TSCONFIG_JSON = JSON.stringify(TSCONFIG, null, 2); + +export const WORKFLOW_DIAGNOSTICS_FILENAME = 'workflow-diagnostics.mts'; + +export async function loadWorkflowDiagnosticsWorker(): Promise { + return await readFile(resolve(__dirname, '../../assets', WORKFLOW_DIAGNOSTICS_FILENAME), 'utf8'); +} diff --git a/packages/@n8n/instance-ai/src/workspace/snapshot-manager.ts b/packages/@n8n/instance-ai/src/workspace/snapshot-manager.ts index e1b66ab327b..83a87e32d04 100644 --- a/packages/@n8n/instance-ai/src/workspace/snapshot-manager.ts +++ b/packages/@n8n/instance-ai/src/workspace/snapshot-manager.ts @@ -27,6 +27,7 @@ import { builderTemplatesOptionsFromEnv, } from './builder-templates-service'; import { PACKAGE_JSON, TSCONFIG_JSON, BUILD_MJS, NPM_INSTALL_FLAGS } from './sandbox-setup'; +import { loadWorkflowDiagnosticsWorker, WORKFLOW_DIAGNOSTICS_FILENAME } from './sandbox-typescript'; import { stageWorkspaceFilesForImage } from './snapshot-image-context'; import { buildRuntimeSkillWorkspaceBundle } from '../skills/materialize-runtime-skills'; import { loadInstanceAiRuntimeSkillSource } from '../skills/runtime-skills'; @@ -213,6 +214,10 @@ export class SnapshotManager { workspaceFiles.set(`${DAYTONA_WORKSPACE_ROOT}/package.json`, PACKAGE_JSON); workspaceFiles.set(`${DAYTONA_WORKSPACE_ROOT}/tsconfig.json`, TSCONFIG_JSON); workspaceFiles.set(`${DAYTONA_WORKSPACE_ROOT}/build.mjs`, BUILD_MJS); + workspaceFiles.set( + `${DAYTONA_WORKSPACE_ROOT}/${WORKFLOW_DIAGNOSTICS_FILENAME}`, + await loadWorkflowDiagnosticsWorker(), + ); const { stagingDir } = await stageWorkspaceFilesForImage( workspaceFiles, diff --git a/packages/@n8n/instance-ai/tsconfig.json b/packages/@n8n/instance-ai/tsconfig.json index ca0d4fafbb8..2fb2498cdd7 100644 --- a/packages/@n8n/instance-ai/tsconfig.json +++ b/packages/@n8n/instance-ai/tsconfig.json @@ -13,5 +13,5 @@ }, "types": ["node", "vitest/globals", "vite/client"] }, - "include": ["src/**/*.ts", "src/**/*.json"] + "include": ["src/**/*.ts", "src/**/*.json", "assets/**/*.mts"] } diff --git a/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator-batching.test.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator-batching.test.ts new file mode 100644 index 00000000000..22ba49ea33e --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator-batching.test.ts @@ -0,0 +1,100 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { getSchemaBaseDirs, setSchemaBaseDirs, validateNodeConfig } from './schema-validator'; + +describe('schema error batching', () => { + let root: string; + let originalDirs: string[]; + const valid = { mode: 'send', first: true, second: true, third: true, fourth: true, label: 'ok' }; + + beforeAll(() => { + originalDirs = getSchemaBaseDirs(); + root = mkdtempSync(join(tmpdir(), 'sdk-schema-batching-')); + const nodeDir = join(root, 'nodes', 'custom-pkg', 'batch'); + mkdirSync(nodeDir, { recursive: true }); + writeFileSync( + join(nodeDir, 'v1.schema.js'), + `module.exports = ({ z }) => z.union([ + z.object({ parameters: z.object({ + mode: z.literal('send'), + first: z.boolean(), second: z.boolean(), third: z.boolean(), fourth: z.boolean(), + label: z.string().min(2), + }) }), + z.object({ parameters: z.object({ mode: z.literal('list') }) }), +]);`, + ); + writeFileSync( + join(nodeDir, 'v2.schema.js'), + `module.exports = ({ z }) => z.union([ + z.object({ parameters: z.object({ mode: z.literal('send'), + first: z.string().min(2), second: z.string().min(2), third: z.string().min(2), fourth: z.string().min(2), + }) }), + z.object({ parameters: z.object({ mode: z.literal('list') }) }), +]);`, + ); + setSchemaBaseDirs([root]); + }); + + afterAll(() => { + setSchemaBaseDirs(originalDirs); + rmSync(root, { recursive: true, force: true }); + }); + + it('reports all four type failures in the selected variant', () => { + const result = validateNodeConfig('custom-pkg.batch', 1, { + parameters: { ...valid, first: {}, second: {}, third: {}, fourth: {} }, + }); + expect(result.valid).toBe(false); + const message = result.errors.map((e) => e.message).join('\n'); + for (const field of ['first', 'second', 'third', 'fourth']) { + expect(message).toContain(`"parameters.${field}" (expected boolean, got object)`); + } + expect(message).not.toContain('more'); + }); + + it('reports all four non-type failures in the selected variant', () => { + const result = validateNodeConfig('custom-pkg.batch', 2, { + parameters: { mode: 'send', first: '', second: '', third: '', fourth: '' }, + }); + expect(result.valid).toBe(false); + const message = result.errors.map((e) => e.message).join('\n'); + for (const field of ['first', 'second', 'third', 'fourth']) { + expect(message).toContain(`"parameters.${field}"`); + } + }); + + it('reports other issue classes alongside a type failure', () => { + const result = validateNodeConfig('custom-pkg.batch', 1, { + parameters: { ...valid, first: {}, label: '' }, + }); + const message = result.errors.map((e) => e.message).join('\n'); + expect(message).toContain( + 'Field "parameters.first" has wrong type: expected boolean, got object.', + ); + expect(message).toContain('"parameters.label"'); + }); + + it('preserves the single-error wording', () => { + const result = validateNodeConfig('custom-pkg.batch', 1, { + parameters: { ...valid, first: {} }, + }); + expect(result.errors.map((e) => e.message)).toEqual([ + 'Field "parameters.first" has wrong type: expected boolean, got object.', + ]); + }); + + it('preserves discriminator guidance without reporting incompatible variants', () => { + const result = validateNodeConfig('custom-pkg.batch', 1, { + parameters: { mode: 'invalid' }, + }); + expect(result.errors.map((e) => e.message)).toEqual([ + 'Invalid value for "parameters.mode": got "invalid", expected one of: "send", "list".', + ]); + }); + + it('accepts the corrected configuration', () => { + expect(validateNodeConfig('custom-pkg.batch', 1, { parameters: valid }).valid).toBe(true); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.ts b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.ts index 260660def3c..4c4f707f016 100644 --- a/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.ts +++ b/packages/@n8n/workflow-sdk/src/validation/node-parameter-schema/schema-validator.ts @@ -566,34 +566,32 @@ function extractUnionErrorSummary( } } + const messages: string[] = []; if (typeMismatches.length > 0) { // Find unique type mismatches - const uniqueMismatches = new Map(); + const uniqueMismatches = new Map< + string, + { path: string; expected: string; received: string } + >(); for (const m of typeMismatches) { - if (!uniqueMismatches.has(m.path)) { - uniqueMismatches.set(m.path, { expected: m.expected, received: m.received }); - } + uniqueMismatches.set(JSON.stringify(m), m); } if (uniqueMismatches.size === 1) { - const [path, { expected, received }] = [...uniqueMismatches.entries()][0]; - return { message: `Field "${path}" has wrong type: expected ${expected}, got ${received}.` }; + const { path, expected, received } = [...uniqueMismatches.values()][0]; + messages.push(`Field "${path}" has wrong type: expected ${expected}, got ${received}.`); + } else { + const summaries = [...uniqueMismatches.values()].map( + ({ path, expected, received }) => `"${path}" (expected ${expected}, got ${received})`, + ); + messages.push(`Type mismatches: ${summaries.join(', ')}.`); } - - // Multiple type mismatches - summarize - const paths = [...uniqueMismatches.keys()].slice(0, 3); - const summaries = paths.map((p) => { - const { expected, received } = uniqueMismatches.get(p)!; - return `"${p}" (expected ${expected}, got ${received})`; - }); - const more = uniqueMismatches.size > 3 ? ` and ${uniqueMismatches.size - 3} more` : ''; - return { message: `Type mismatches: ${summaries.join(', ')}${more}.` }; } - // Fallback: show the first few specific issues - const specificIssues = bestPathIssues - .filter((i: ZodIssue) => i.code !== 'invalid_union') - .slice(0, 3); + // Include other failures from the selected variant, even when types also fail. + const specificIssues = bestPathIssues.filter( + (i) => i.code !== 'invalid_union' && i.code !== 'invalid_type', + ); if (specificIssues.length > 0) { const summaries = specificIssues.map((iss: ZodIssue) => { const path = iss.path.join('.') || 'config'; @@ -602,10 +600,10 @@ function extractUnionErrorSummary( } return `"${path}": ${iss.message}`; }); - return { message: `Validation failed: ${summaries.join('; ')}.` }; + messages.push(`Validation failed: ${[...new Set(summaries)].join('; ')}.`); } - return null; + return messages.length > 0 ? { message: messages.join(' ') } : null; } /**