mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(core): Return available build errors in one tool result (#38231)
This commit is contained in:
parent
5afb7e8102
commit
e36572e582
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
68
packages/@n8n/instance-ai/assets/workflow-diagnostics.mts
Normal file
68
packages/@n8n/instance-ai/assets/workflow-diagnostics.mts
Normal 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();
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/**/*"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,5 +13,5 @@
|
|||
},
|
||||
"types": ["node", "vitest/globals", "vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.json"]
|
||||
"include": ["src/**/*.ts", "src/**/*.json", "assets/**/*.mts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user