fix(core): Return available build errors in one tool result (#38231)

This commit is contained in:
Predrag Ristic 2026-09-16 14:56:51 +00:00 committed by GitHub
parent 5afb7e8102
commit e36572e582
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 737 additions and 100 deletions

View File

@ -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<SandboxCommandResult>;
processes?: {
spawn: (
command: string,
options?: { cwd?: string; abortSignal?: AbortSignal },
options?: RunInSandboxOptions,
) => Promise<{ wait: () => Promise<SandboxCommandResult> }>;
};
};
@ -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 };
}

View File

@ -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<void> {
// 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();

View File

@ -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 <source-file>
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.

View File

@ -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/**/*"

View File

@ -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<BuildToolOutput>(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<BuildToolOutput>(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<BuildToolOutput>(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);
});

View File

@ -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<string[]>(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(
[],
);
});
});

View File

@ -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<object>()),
runInSandbox: vi.fn(),
}));
const context = mock<InstanceAiContext>({
workspace: mock<NonNullable<InstanceAiContext['workspace']>>(),
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' });
});
});

View File

@ -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,

View File

@ -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<string[]> {
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);
}
}

View File

@ -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);
});

View File

@ -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'),

View File

@ -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<string, string> = {
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(

View File

@ -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<string> {
return await readFile(resolve(__dirname, '../../assets', WORKFLOW_DIAGNOSTICS_FILENAME), 'utf8');
}

View File

@ -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,

View File

@ -13,5 +13,5 @@
},
"types": ["node", "vitest/globals", "vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.json"]
"include": ["src/**/*.ts", "src/**/*.json", "assets/**/*.mts"]
}

View File

@ -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);
});
});

View File

@ -566,34 +566,32 @@ function extractUnionErrorSummary(
}
}
const messages: string[] = [];
if (typeMismatches.length > 0) {
// Find unique type mismatches
const uniqueMismatches = new Map<string, { expected: string; received: string }>();
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;
}
/**