diff --git a/.boundaries-baseline.json b/.boundaries-baseline.json index 95a7a4e2fed..ee2a2d16fb2 100644 --- a/.boundaries-baseline.json +++ b/.boundaries-baseline.json @@ -1,3 +1,3 @@ { - "issues": 152 + "issues": 141 } diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index 801eb17e342..79c993cb20c 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -960,7 +960,7 @@ from the master ruleset, and delete this section (tracked in DEVP-887). **`blacksmith-4vcpu-ubuntu-2204`** - Unit tests (parallelized), linting (parallel file processing), typechecking (CPU-intensive), E2E test shards -**`blacksmith-8vcpu-ubuntu-2204`** - Heavy parallel workloads +**`blacksmith-8vcpu-ubuntu-2204`** - Heavy parallel workloads. The PR `install-and-build` job uses it with the default runner provider. Most PR jobs wait for that job, and a cold build keeps all 4 vCPUs of a smaller runner busy. ### Runner Provider Toggle diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index d7c3806d1a0..8b100f308e0 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -11,7 +11,7 @@ concurrency: jobs: install-and-build: name: Install & Build - runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }} + runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-8vcpu-ubuntu-2204' }} env: NODE_OPTIONS: '--max-old-space-size=7168' CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -31,12 +31,16 @@ jobs: db_migrations: ${{ fromJSON(steps.ci-filter.outputs.results)['db-migrations'] == true }} e2e_performance: ${{ fromJSON(steps.ci-filter.outputs.results)['e2e-performance'] == true }} engine_e2e: ${{ fromJSON(steps.ci-filter.outputs.results)['engine-e2e'] == true }} + # The main E2E suite runs. Read by e2e-matrix and e2e. + run_e2e: >- + ${{ github.repository == 'n8n-io/n8n' && github.event_name != 'merge_group' && + (fromJSON(steps.ci-filter.outputs.results).runtime == true + || fromJSON(steps.ci-filter.outputs.results).e2e == true + || contains(github.event.pull_request.labels.*.name, 'force-e2e')) }} instance_ai_workflow_eval: ${{ fromJSON(steps.ci-filter.outputs.results)['instance-ai-workflow-eval'] == true }} commit_sha: ${{ steps.commit-sha.outputs.sha }} merge_base: ${{ steps.ci-filter.outputs.merge-base }} - matrix: ${{ steps.generate-matrix.outputs.matrix }} db_test_matrix: ${{ steps.generate-db-test-matrix.outputs.db_test_matrix }} - skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }} affected_packages: ${{ steps.affected-packages.outputs.list }} changed_files: ${{ steps.ci-filter.outputs.changed-files }} currents_record_key: ${{ secrets.CURRENTS_RECORD_KEY != '' && 'present' || '' }} @@ -227,20 +231,6 @@ jobs: if: fromJSON(steps.ci-filter.outputs.results).ci run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY" - - name: Generate shard matrix - id: generate-matrix - if: github.event_name != 'merge_group' && (fromJSON(steps.ci-filter.outputs.results).ci || fromJSON(steps.ci-filter.outputs.results).e2e) - env: - CHANGED_FILES: ${{ steps.ci-filter.outputs.changed-files }} - MERGE_BASE: ${{ steps.ci-filter.outputs.merge-base }} - PLAYWRIGHT_GREP_INVERT: ${{ github.event.pull_request.head.repo.fork == true && '@licensed' || '' }} - PLAYWRIGHT_PROJECT: ${{ github.event.pull_request.head.repo.fork == true && 'sqlite:e2e' || 'multi-main:e2e' }} - run: | - FILES_CSV=$(echo "$CHANGED_FILES" | tr '\n' ',' | sed 's/,$//') - MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 20 --orchestrate --impact "--project=$PLAYWRIGHT_PROJECT" "--files=$FILES_CSV" "--base=$MERGE_BASE") - echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" - echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT" - # Generated here because a reusable workflow cannot resolve its own matrix. # PRs run SQLite plus the primary Postgres leg. The merge queue runs the # full Postgres range, so every merge still gates on the older majors. @@ -347,7 +337,7 @@ jobs: # (each of which invokes prepare-docker internally) short-circuit to a # cache hit instead of racing to rebuild. prepare-docker: - name: Prepare Docker + name: Prepare E2E needs: [install-and-build, format-check] if: >- github.repository == 'n8n-io/n8n' && @@ -364,25 +354,66 @@ jobs: branch: ${{ needs.install-and-build.outputs.commit_sha }} secrets: inherit + # Runs next to prepare-docker, so the matrix is off the path of the jobs that + # wait for install-and-build. Only the e2e job reads it. + e2e-matrix: + name: Prepare E2E / Shard matrix + needs: install-and-build + if: needs.install-and-build.outputs.run_e2e == 'true' + runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }} + outputs: + matrix: ${{ steps.generate-matrix.outputs.matrix }} + skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }} + steps: + - uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0 + with: + ref: ${{ needs.install-and-build.outputs.commit_sha }} + fetch-depth: 1 + + # Impact selection reads package.json at the merge base. + - name: Fetch merge base + env: + MERGE_BASE: ${{ needs.install-and-build.outputs.merge_base }} + run: | + if [ -n "$MERGE_BASE" ] && ! git cat-file -e "$MERGE_BASE^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=1 origin "$MERGE_BASE" + fi + + - name: Setup Node.js + uses: ./.github/actions/setup-nodejs + with: + build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... + + - name: Generate shard matrix + id: generate-matrix + env: + CHANGED_FILES: ${{ needs.install-and-build.outputs.changed_files }} + MERGE_BASE: ${{ needs.install-and-build.outputs.merge_base }} + PLAYWRIGHT_GREP_INVERT: ${{ github.event.pull_request.head.repo.fork == true && '@licensed' || '' }} + PLAYWRIGHT_PROJECT: ${{ github.event.pull_request.head.repo.fork == true && 'sqlite:e2e' || 'multi-main:e2e' }} + run: | + FILES_CSV=$(echo "$CHANGED_FILES" | tr '\n' ',' | sed 's/,$//') + MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 20 --orchestrate --impact "--project=$PLAYWRIGHT_PROJECT" "--files=$FILES_CSV" "--base=$MERGE_BASE") + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT" + # Full e2e run. Internal PRs run multi-main (postgres + redis + caddy + 2 mains + 1 worker). # Fork PRs run sqlite-only and skip @licensed tests (no enterprise license secrets on forks). e2e: name: E2E - needs: [install-and-build, prepare-docker] + needs: [install-and-build, prepare-docker, e2e-matrix] if: >- needs.prepare-docker.result == 'success' && - (needs.install-and-build.outputs.runtime == 'true' - || needs.install-and-build.outputs.e2e == 'true' - || contains(github.event.pull_request.labels.*.name, 'force-e2e')) && - needs.install-and-build.outputs.skip_tests != 'true' && - github.event_name != 'merge_group' + needs.e2e-matrix.result == 'success' && + needs.install-and-build.outputs.run_e2e == 'true' && + needs.e2e-matrix.outputs.skip_tests != 'true' uses: ./.github/workflows/test-e2e-reusable.yml with: branch: ${{ needs.install-and-build.outputs.commit_sha }} test-mode: docker-artifact test-command: ${{ github.event.pull_request.head.repo.fork == true && 'pnpm --filter=n8n-playwright test:container:sqlite:e2e --grep-invert=@licensed' || 'pnpm --filter=n8n-playwright test:container:multi-main:e2e' }} workers: '1' - pre-generated-matrix: ${{ needs.install-and-build.outputs.matrix }} + pre-generated-matrix: ${{ needs.e2e-matrix.outputs.matrix }} artifact-prefix: e2e currents-record-key: ${{ needs.install-and-build.outputs.currents_record_key }} secrets: inherit @@ -510,6 +541,7 @@ jobs: typecheck, lint, check-packaging, + e2e-matrix, e2e, e2e-engine, dev-server-smoke, diff --git a/package.json b/package.json index 796150c693d..c51e303cccc 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "stylelint": "catalog:", "tsc-alias": "^1.8.10", "tsc-watch": "^6.2.0", - "turbo": "2.9.15", + "turbo": "2.11.4", "typescript": "6.0.2", "yaml": "catalog:", "zx": "^8.8.5" diff --git a/packages/@n8n/instance-ai/package.json b/packages/@n8n/instance-ai/package.json index 1d0eb957e38..4335785cdbe 100644 --- a/packages/@n8n/instance-ai/package.json +++ b/packages/@n8n/instance-ai/package.json @@ -41,6 +41,11 @@ "require": "./dist/index.js", "import": "./dist/index.js" }, + "./errors": { + "types": "./dist/errors/index.d.ts", + "require": "./dist/errors/index.js", + "import": "./dist/errors/index.js" + }, "./parsers": { "require": "./dist/parsers/index.js", "import": "./dist/parsers/index.js", @@ -55,6 +60,9 @@ }, "typesVersions": { "*": { + "errors": [ + "dist/errors/index.d.ts" + ], "parsers": [ "dist/parsers/index.d.ts" ], diff --git a/packages/@n8n/instance-ai/src/errors/index.ts b/packages/@n8n/instance-ai/src/errors/index.ts new file mode 100644 index 00000000000..1b9a2695367 --- /dev/null +++ b/packages/@n8n/instance-ai/src/errors/index.ts @@ -0,0 +1,6 @@ +// A light entry point for callers that need only the error classes, without the +// rest of the package behind the root barrel. +export { FolderResolutionError } from './folder-resolution.error'; +export { WorkflowEditorLockedError } from './workflow-editor-locked.error'; +export { WorkflowNotFoundError } from './workflow-not-found.error'; +export { WorkflowSaveConflictError } from './workflow-save-conflict.error'; diff --git a/packages/@n8n/instance-ai/src/index.ts b/packages/@n8n/instance-ai/src/index.ts index 6c90c0cb096..02603733687 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -203,10 +203,12 @@ export { parseModelHeadersJson } from './utils/parse-model-headers'; export { modelConfigId } from './utils/model-config-id'; export { isEndpointModelConfig } from './utils/modal-session'; export { resolveCustomModelExperimentDefaultsFromEnv } from './utils/custom-model-defaults'; -export { WorkflowSaveConflictError } from './errors/workflow-save-conflict.error'; -export { WorkflowNotFoundError } from './errors/workflow-not-found.error'; -export { WorkflowEditorLockedError } from './errors/workflow-editor-locked.error'; -export { FolderResolutionError } from './errors/folder-resolution.error'; +export { + WorkflowSaveConflictError, + WorkflowNotFoundError, + WorkflowEditorLockedError, + FolderResolutionError, +} from './errors'; export { LEGACY_PLANNED_TASK_KINDS, PLANNED_TASK_KINDS, diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts index c1b7e7349be..892556acc7c 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts @@ -584,6 +584,96 @@ function findNestedSchemaDir(dir: string, targetNames: string[]): string | undef return undefined; } +/** + * Folder name (lowercased) to `__schema__` path for one schema root. It holds the + * same answers as the direct search in `findSchemaDirectory`, but it reads each + * directory once. + */ +interface SchemaRootIndex { + /** Direct children of the root. The flat search checks these first. */ + flat: Map; + /** First match in the pre-order walk that `findNestedSchemaDir` does. */ + nested: Map; +} + +function buildSchemaRootIndex(root: string): SchemaRootIndex { + const index: SchemaRootIndex = { flat: new Map(), nested: new Map() }; + + const walk = (dir: string, depth: number) => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const entryPath = path.join(dir, entry.name); + const key = entry.name.toLowerCase(); + const schemaPath = path.join(entryPath, '__schema__'); + if (fs.existsSync(schemaPath)) { + if (depth === 0 && !index.flat.has(key)) index.flat.set(key, schemaPath); + if (!index.nested.has(key)) index.nested.set(key, schemaPath); + } + + if (entry.name !== '__schema__' && entry.name !== 'node_modules') { + walk(entryPath, depth + 1); + } + } + }; + + walk(root, 0); + return index; +} + +/** + * Schema root indexes for the active generation run. A run looks up hundreds of + * node versions, and each miss used to walk the whole `dist/nodes` tree again. + * The indexes live only for one run, because other callers (and tests) can add + * schema directories between calls. + */ +let activeSchemaIndexes: Map | undefined; + +/** Run `fn` with schema directory lookups served from a one-time index of each root. */ +export async function withSchemaDirIndex(fn: () => Promise): Promise { + if (activeSchemaIndexes) return await fn(); + + activeSchemaIndexes = new Map(); + try { + return await fn(); + } finally { + activeSchemaIndexes = undefined; + } +} + +function indexedSchemaDirectory( + indexes: Map, + roots: string[], + baseName: string, +): string | undefined { + const rootIndexes = roots.map((root) => { + let index = indexes.get(root); + if (!index) { + index = buildSchemaRootIndex(root); + indexes.set(root, index); + } + return index; + }); + + const key = baseName.toLowerCase(); + for (const index of rootIndexes) { + const found = index.flat.get(key); + if (found) return found; + } + for (const index of rootIndexes) { + const found = index.nested.get(key); + if (found) return found; + } + return undefined; +} + /** * Schema roots to search, in priority order: the package currently being * generated (generate-node-defs-cli runs with CWD = that package, so e.g. @@ -604,7 +694,7 @@ export function schemaSearchRoots(): string[] { * @param baseName The base node name (e.g., 'gmail') * @returns Path to the __schema__ directory, or undefined if not found */ -function findSchemaDirectory(baseName: string, schemaPath?: string): string | undefined { +export function findSchemaDirectory(baseName: string, schemaPath?: string): string | undefined { const roots = schemaSearchRoots(); // If explicit schemaPath is provided, use it directly @@ -617,6 +707,10 @@ function findSchemaDirectory(baseName: string, schemaPath?: string): string | un } } + if (activeSchemaIndexes) { + return indexedSchemaDirectory(activeSchemaIndexes, roots, baseName); + } + const possibleNames = [ baseName.charAt(0).toUpperCase() + baseName.slice(1), // Gmail baseName, // gmail @@ -4611,6 +4705,10 @@ export interface GenerationResult { * @returns Result with count of nodes processed */ export async function orchestrateGeneration(options: GenerationOptions): Promise { + return await withSchemaDirIndex(async () => await generateAll(options)); +} + +async function generateAll(options: GenerationOptions): Promise { const { nodes, outputDir } = options; // Group nodes by package, filtering hidden diff --git a/packages/@n8n/workflow-sdk/src/generate-types/schema-dir-index.test.ts b/packages/@n8n/workflow-sdk/src/generate-types/schema-dir-index.test.ts new file mode 100644 index 00000000000..2476bb0916e --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/generate-types/schema-dir-index.test.ts @@ -0,0 +1,98 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { findSchemaDirectory, withSchemaDirIndex } from './generate-types'; + +function addSchemaDir(root: string, relativeDir: string): string { + const schemaDir = path.join(root, relativeDir, '__schema__'); + fs.mkdirSync(path.join(schemaDir, 'v1.0.0'), { recursive: true }); + fs.writeFileSync(path.join(schemaDir, 'v1.0.0', 'output.json'), '{}'); + return schemaDir; +} + +describe('schema directory index', () => { + let packageDir: string; + let nodesRoot: string; + let originalCwd: string; + + beforeEach(() => { + originalCwd = process.cwd(); + // realpath: on macOS the temp dir is a symlink, and process.cwd() resolves it. + packageDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'schema-dir-index-test-'))); + nodesRoot = path.join(packageDir, 'dist', 'nodes'); + fs.mkdirSync(nodesRoot, { recursive: true }); + // schemaSearchRoots() puts /dist/nodes ahead of nodes-base. + process.chdir(packageDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(packageDir, { recursive: true, force: true }); + }); + + const names = [ + '__IdxTarget__', + '__idxchainllm__', + '__IdxSheets__', + '__IdxDuplicate__', + '__IdxHidden__', + '__IdxMissing__', + ]; + + function buildTree() { + // A nested match that sorts before the flat match. The flat match must win. + addSchemaDir(nodesRoot, 'AAA/__IdxTarget__'); + const flatTarget = addSchemaDir(nodesRoot, '__IdxTarget__'); + // Folder casing differs from the node name. + const chain = addSchemaDir(nodesRoot, 'chains/__IDXCHAINLLM__'); + // A folder with the node name but no schema, with a match below it. + fs.mkdirSync(path.join(nodesRoot, '__IdxSheets__'), { recursive: true }); + const sheets = addSchemaDir(nodesRoot, '__IdxSheets__/V2/__IdxSheets__'); + // Two nested matches. Walk order decides, the same as the direct search. + addSchemaDir(nodesRoot, 'Group1/__IdxDuplicate__'); + addSchemaDir(nodesRoot, 'Group2/__IdxDuplicate__'); + // The search skips node_modules. + addSchemaDir(nodesRoot, 'node_modules/__IdxHidden__'); + return { flatTarget, chain, sheets }; + } + + it('returns the same directories as the direct search', async () => { + const { flatTarget, chain, sheets } = buildTree(); + + const direct = names.map((name) => findSchemaDirectory(name)); + const indexed = await withSchemaDirIndex( + async () => await Promise.resolve(names.map((name) => findSchemaDirectory(name))), + ); + + expect(indexed).toEqual(direct); + expect(indexed[0]).toBe(flatTarget); + expect(indexed[1]).toBe(chain); + expect(indexed[2]).toBe(sheets); + expect(indexed[3]).toBeDefined(); + expect(indexed[4]).toBeUndefined(); + expect(indexed[5]).toBeUndefined(); + }); + + it('keeps an explicit schema path ahead of the name lookup', async () => { + buildTree(); + const explicit = addSchemaDir(nodesRoot, 'custom/place'); + + const result = await withSchemaDirIndex( + async () => await Promise.resolve(findSchemaDirectory('__IdxTarget__', 'custom/place')), + ); + + expect(result).toBe(explicit); + }); + + it('does not keep the index after the run ends', async () => { + await withSchemaDirIndex(async () => await Promise.resolve(findSchemaDirectory('__IdxLate__'))); + const late = addSchemaDir(nodesRoot, '__IdxLate__'); + + expect(findSchemaDirectory('__IdxLate__')).toBe(late); + const nextRun = await withSchemaDirIndex( + async () => await Promise.resolve(findSchemaDirectory('__IdxLate__')), + ); + expect(nextRun).toBe(late); + }); +}); diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index 3c1939df57d..f92ee2dfa07 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -34,14 +34,26 @@ function generateUserManagementEmailTemplates() { shell.mkdir('-p', destinationDir); - const templates = glob.sync('*.mjml', { cwd: sourceDir }); - templates.forEach((template) => { - if (template.startsWith('_')) return; - const source = path.resolve(sourceDir, template); - const destination = path.resolve(destinationDir, template.replace(/\.mjml$/, '.handlebars')); - const command = `pnpm mjml --output "${destination}" "${source}"`; - shell.exec(command, { silent: false }); + const templates = glob.sync('*.mjml', { cwd: sourceDir }).filter((t) => !t.startsWith('_')); + + // One mjml process for all templates: each `pnpm mjml` start costs about 0.3s. + // With several inputs, mjml writes `.html` into the output directory. + const sources = templates.map((template) => `"${path.resolve(sourceDir, template)}"`).join(' '); + const result = shell.exec(`pnpm mjml ${sources} --output "${destinationDir}${path.sep}"`, { + silent: false, }); + if (result.code !== 0) { + throw new Error(`mjml failed to compile the email templates (exit code ${result.code})`); + } + + for (const template of templates) { + const name = template.replace(/\.mjml$/, ''); + const compiled = path.resolve(destinationDir, `${name}.html`); + if (!existsSync(compiled)) { + throw new Error(`mjml did not write ${compiled}`); + } + shell.mv(compiled, path.resolve(destinationDir, `${name}.handlebars`)); + } shell.cp(path.resolve(sourceDir, 'n8n-logo.png'), destinationDir); } @@ -74,14 +86,16 @@ async function buildPublicApiSpec() { // 1. Generate decorated route OpenAPI specs from source. generateDocs(v1Dir); - // 2. Bundle both sources. + // 2. Bundle both sources in one redocly process. With several inputs, redocly + // writes each bundle into the output directory under its source file name. const v1DistDir = path.resolve(ROOT_DIR, 'dist', 'public-api', 'v1'); + bundleSpecs( + [path.join(v1Dir, SPEC_FILENAME), path.join(v1Dir, DECORATOR_ROOT_FILENAME)], + v1DistDir, + ); const eovDistSpec = path.join(v1DistDir, SPEC_FILENAME); - bundleSpec(path.join(v1Dir, SPEC_FILENAME), eovDistSpec); - const decoratorDistSpec = path.join(v1DistDir, DECORATOR_ROOT_FILENAME); - bundleSpec(path.join(v1Dir, DECORATOR_ROOT_FILENAME), decoratorDistSpec); // 3. Merge the two specs into a single OpenAPI document, writing back to the eov spec path. const eovDoc = parseYaml(readFileSync(eovDistSpec, 'utf8')); @@ -125,13 +139,25 @@ async function loadOpenApiGenerator() { return generator; } -// Bundles a spec through redocly, resolving all $refs into a single file at `distPath`. -function bundleSpec(sourcePath, distPath) { - const result = shell.exec(`pnpm openapi bundle "${sourcePath}" --output "${distPath}"`, { +// Bundles specs through redocly, resolving all $refs. Each source becomes one file in `distDir`. +function bundleSpecs(sourcePaths, distDir) { + const distPaths = sourcePaths.map((sourcePath) => path.join(distDir, path.basename(sourcePath))); + // Remove old output, so that the check below cannot pass on a stale file. + for (const distPath of distPaths) rmSync(distPath, { force: true }); + + const sources = sourcePaths.map((sourcePath) => `"${sourcePath}"`).join(' '); + const result = shell.exec(`pnpm openapi bundle ${sources} --output "${distDir}"`, { silent: true, }); if (result.code !== 0) { - throw new Error(`redocly failed to bundle ${sourcePath}:\n${result.stderr || result.stdout}`); + throw new Error( + `redocly failed to bundle ${sourcePaths.join(', ')}:\n${result.stderr || result.stdout}`, + ); + } + for (const [i, distPath] of distPaths.entries()) { + if (!existsSync(distPath)) { + throw new Error(`redocly did not write the bundle for ${sourcePaths[i]} to ${distPath}`); + } } } diff --git a/packages/cli/src/modules/instance-ai/__tests__/agent-tree-builder.test.ts b/packages/cli/src/modules/instance-ai/__tests__/agent-tree-builder.test.ts index c6903eecf26..4b1f6b2eb02 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/agent-tree-builder.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/agent-tree-builder.test.ts @@ -1,24 +1,5 @@ -import type { InstanceAiAgentNode, InstanceAiEvent } from '@n8n/api-types'; - -// `@n8n/instance-ai` source util imported dynamically (no built entry for this -// deep subpath); loaded in `beforeAll` to avoid top-level `await`. -let buildAgentTreeFromEvents: (events: InstanceAiEvent[]) => InstanceAiAgentNode; -let findAgentNodeInTree: ( - tree: InstanceAiAgentNode, - agentId: string, -) => InstanceAiAgentNode | undefined; - -beforeAll(async () => { - ({ buildAgentTreeFromEvents, findAgentNodeInTree } = (await import( - '../../../../../@n8n/instance-ai/src/utils/agent-tree.js' - )) as { - buildAgentTreeFromEvents: (events: InstanceAiEvent[]) => InstanceAiAgentNode; - findAgentNodeInTree: ( - tree: InstanceAiAgentNode, - agentId: string, - ) => InstanceAiAgentNode | undefined; - }); -}); +import type { InstanceAiEvent } from '@n8n/api-types'; +import { buildAgentTreeFromEvents, findAgentNodeInTree } from '@n8n/instance-ai'; describe('buildAgentTreeFromEvents', () => { it('should build a tree from run-start + text-delta + run-finish', () => { diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts index d958f7979e4..bd36130dc75 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts @@ -1,15 +1,8 @@ // The barrel is mocked so this file exercises the adapter's own wiring rather than pulling the // whole instance-ai package in behind it, matching the other adapter tests. vi.mock('@n8n/instance-ai', async () => { - const { WorkflowSaveConflictError } = await import( - '../../../../../@n8n/instance-ai/src/errors/workflow-save-conflict.error.js' - ); - const { WorkflowNotFoundError } = await import( - '../../../../../@n8n/instance-ai/src/errors/workflow-not-found.error.js' - ); - const { WorkflowEditorLockedError } = await import( - '../../../../../@n8n/instance-ai/src/errors/workflow-editor-locked.error.js' - ); + const { WorkflowSaveConflictError, WorkflowNotFoundError, WorkflowEditorLockedError } = + await import('@n8n/instance-ai/errors'); return { WorkflowSaveConflictError, WorkflowNotFoundError, diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts index 2197b543ec5..406b039ee8d 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts @@ -1,8 +1,6 @@ // Mock the barrel import so these adapter tests only exercise local formatting helpers. vi.mock('@n8n/instance-ai', async () => { - const { WorkflowNotFoundError } = await import( - '../../../../../@n8n/instance-ai/src/errors/workflow-not-found.error.js' - ); + const { WorkflowNotFoundError } = await import('@n8n/instance-ai/errors'); return { WorkflowNotFoundError, wrapUntrustedData(content: string, source: string, label?: string): string { diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index c82ca6e9f2f..36bb2939088 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -1,17 +1,11 @@ // Mock the barrel import so these adapter tests only exercise local formatting helpers. vi.mock('@n8n/instance-ai', async () => { - const { WorkflowSaveConflictError } = await import( - '../../../../../@n8n/instance-ai/src/errors/workflow-save-conflict.error.js' - ); - const { WorkflowNotFoundError } = await import( - '../../../../../@n8n/instance-ai/src/errors/workflow-not-found.error.js' - ); - const { WorkflowEditorLockedError } = await import( - '../../../../../@n8n/instance-ai/src/errors/workflow-editor-locked.error.js' - ); - const { FolderResolutionError } = await import( - '../../../../../@n8n/instance-ai/src/errors/folder-resolution.error.js' - ); + const { + WorkflowSaveConflictError, + WorkflowNotFoundError, + WorkflowEditorLockedError, + FolderResolutionError, + } = await import('@n8n/instance-ai/errors'); return { WorkflowSaveConflictError, WorkflowNotFoundError, @@ -1814,9 +1808,11 @@ import type { DataTableRepository } from '@/modules/data-table/data-table.reposi import type { DataTableService } from '@/modules/data-table/data-table.service'; import type { InstanceWriteAccessService } from '@/services/instance-write-access.service'; import type { WorkflowJSON } from '@n8n/workflow-sdk'; -import { WorkflowEditorLockedError } from '../../../../../@n8n/instance-ai/src/errors/workflow-editor-locked.error'; -import { WorkflowNotFoundError } from '../../../../../@n8n/instance-ai/src/errors/workflow-not-found.error'; -import { WorkflowSaveConflictError } from '../../../../../@n8n/instance-ai/src/errors/workflow-save-conflict.error'; +import { + WorkflowEditorLockedError, + WorkflowNotFoundError, + WorkflowSaveConflictError, +} from '@n8n/instance-ai/errors'; import type { WorkflowService } from '@/workflows/workflow.service'; import { AiPreferenceScopeFullError } from '@/errors/response-errors/ai-preference-scope-full.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 241a75bea51..4b23b59b350 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -13,8 +13,6 @@ "clean": "rimraf dist .turbo", "dev": "pnpm watch", "typecheck": "tsc --noEmit", - "build": "tsc --noEmit", - "build:unchecked": "pnpm run build", "build-node-dev": "tsc", "format": "biome format --write .", "format:check": "biome ci .", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f059f597eff..17bd4504808 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1141,8 +1141,8 @@ importers: specifier: ^6.2.0 version: 6.2.0(typescript@6.0.2) turbo: - specifier: 2.9.15 - version: 2.9.15 + specifier: 2.11.4 + version: 2.11.4 typescript: specifier: 6.0.2 version: 6.0.2 @@ -14060,33 +14060,33 @@ packages: '@tsconfig/node16@16.1.8': resolution: {integrity: sha512-T/CfdwFry660WjZor56z0F3pxeCllt8KOxWcHFW6ZEuULKUObTDEMdgtctyuJPxwqyWDsvHRfxHaJ4FIICyoqQ==} - '@turbo/darwin-64@2.9.18': - resolution: {integrity: sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw==} + '@turbo/darwin-64@2.11.4': + resolution: {integrity: sha512-fEmcQVa95k79tZjcay5b2A4cpR/VrlNUki4AN7EbA+32vj5C5rBUE1q3i2yOFxsB2n01sOlzIZ+s2p2zADCTig==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.9.15': - resolution: {integrity: sha512-fDSx56oqoFuS+yUQw7hqjQTkjrSLdMcplhuLC8HcSkWC6YrpwEmUUYsPYHPxy4ALvLxnmPQuk6XoSD8tdkjP+g==} + '@turbo/darwin-arm64@2.11.4': + resolution: {integrity: sha512-SgXCVgBC1v+bdE6+KQGV6Kob7nBYo75zxzNFFuA4jLdM0J8lYamdTUoZSHLR0dy+kXVEYZqSC98CKqC17NG3Og==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.9.18': - resolution: {integrity: sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw==} + '@turbo/linux-64@2.11.4': + resolution: {integrity: sha512-MWM7Au6NPJMBmHJAzFzQ/PACYNRpBOqnBkj+7csdR+YhJ+0XBg4Z64wFwLnGFDac0gc+YlasWOZ5t05bGs54aQ==} cpu: [x64] - os: [linux] + os: [android, linux] - '@turbo/linux-arm64@2.9.15': - resolution: {integrity: sha512-cbOaDe1ijz5As+mimOOHgmRMolZZZO7miNBHHp5xdiYMm2Q/Dwu1JVLx/Kw4s7xjocG/oEoHrpHrxpEAIEfNiw==} + '@turbo/linux-arm64@2.11.4': + resolution: {integrity: sha512-rRVOh0UNlLbKPuf70FokqjahO/hCGgBmgvZcNXUPayn8Wk0zXIsVoNKpr0UQUhhR+KMiibvBKkVAGDViDFQHKA==} cpu: [arm64] - os: [linux] + os: [android, linux] - '@turbo/windows-64@2.9.18': - resolution: {integrity: sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw==} + '@turbo/windows-64@2.11.4': + resolution: {integrity: sha512-COr+++1GwDg09XbJdm3sP1gEQApeV43UknYP9h82qet1tAGJrGQAxKVTMrf2JMsMw8MkJRnQj3gAVuQmV1kIyQ==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.9.15': - resolution: {integrity: sha512-fOHEsLcqVdFXLw2ApWv4gxwfHzkUnpo9rHGml+9+dyHj148m/Bc+556kEvb5+4u6prI1LMd8zEZE2HcO6Jn2VQ==} + '@turbo/windows-arm64@2.11.4': + resolution: {integrity: sha512-gh5lwDA2d4aBdLvTANhFsFEgxbBqKZ4m4dCyChw9GoX8b+Esk8H3p25Q+rlWCUVJ2o6Hne10ZTL0QSFfWD4gIA==} cpu: [arm64] os: [win32] @@ -23347,8 +23347,8 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} - turbo@2.9.15: - resolution: {integrity: sha512-VpKvD9Z0Hu/xrGUAYX1wnhfpqv835wIwGqeKfulvBPTOcDap0E3nFwyzCAVV85fB1sBcBDEfTP+7FSW7GzwWSQ==} + turbo@2.11.4: + resolution: {integrity: sha512-nedZtJ2hjWpU/JBFq7Aj1GQBxQ56si+kIUmNZBUX35O35TIuhSBR6/u3yHHmk81U+RBdBcVjvLQ3/iOFzMwKsA==} hasBin: true turndown@7.2.4: @@ -32115,22 +32115,22 @@ snapshots: '@tsconfig/node16@16.1.8': {} - '@turbo/darwin-64@2.9.18': + '@turbo/darwin-64@2.11.4': optional: true - '@turbo/darwin-arm64@2.9.15': + '@turbo/darwin-arm64@2.11.4': optional: true - '@turbo/linux-64@2.9.18': + '@turbo/linux-64@2.11.4': optional: true - '@turbo/linux-arm64@2.9.15': + '@turbo/linux-arm64@2.11.4': optional: true - '@turbo/windows-64@2.9.18': + '@turbo/windows-64@2.11.4': optional: true - '@turbo/windows-arm64@2.9.15': + '@turbo/windows-arm64@2.11.4': optional: true '@tybys/wasm-util@0.10.1': @@ -43527,14 +43527,14 @@ snapshots: tunnel@0.0.6: {} - turbo@2.9.15: + turbo@2.11.4: optionalDependencies: - '@turbo/darwin-64': 2.9.18 - '@turbo/darwin-arm64': 2.9.15 - '@turbo/linux-64': 2.9.18 - '@turbo/linux-arm64': 2.9.15 - '@turbo/windows-64': 2.9.18 - '@turbo/windows-arm64': 2.9.15 + '@turbo/darwin-64': 2.11.4 + '@turbo/darwin-arm64': 2.11.4 + '@turbo/linux-64': 2.11.4 + '@turbo/linux-arm64': 2.11.4 + '@turbo/windows-64': 2.11.4 + '@turbo/windows-arm64': 2.11.4 turndown@7.2.4: dependencies: diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index ac36e7226d2..d2dc100dd0d 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -11,10 +11,8 @@ * fewer issues. Nothing raises it automatically: a count above the baseline * fails the nightly instead. * - * The baseline is the POST-BUILD count: CI's lint job builds dependencies before - * this check runs, and built dist trees surface ~8 extra issues that a cold - * checkout doesn't. Run `pnpm build` before `pnpm boundaries:baseline`, or CI - * will read a higher count than you saw locally. + * Turbo 2.11 skips gitignored files, so built dist trees no longer change the + * count. It also checks dynamic `import()` calls, which 2.9 did not. * * ponytail: parses turbo's "N issues found" line — the ratchet is a single * number, not a per-issue snapshot, so it can't tell a fixed issue from a new